diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d056dad6 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +* @Railroad-Team/railroad-maintainers + +# File Specific +/.github/CODEOWNERS @DaRealTurtyWurty diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..a842ea82 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,71 @@ +# Security Policy + +## Supported Versions + +The following table outlines which versions of Railroad currently receive security updates. + +| Version | Supported | +|----------|------------| +| Main branch (`main`) | ✅ | +| Latest stable release (`vX.Y.Z`) | ✅ | +| Older releases | ❌ | + +We only provide security fixes for the **most recent stable release** and the `main` development branch. +If you're using an older version, please update to the latest release. + +--- + +## Reporting a Vulnerability + +If you discover a vulnerability in Railroad or any related project (such as Switchboard, RailroadLogger, or the Plugin API), **please report it responsibly**. +Do **not** disclose it publicly until it has been patched. + +### 🔒 How to Report +- **Preferred:** [Create a private vulnerability report](https://github.com/Railroad-Team/Railroad/security/advisories/new) +- **Alternative:** Email the maintainers at **security@railroadide.dev** + +Please include the following details: +- A clear description of the issue and its potential impact. +- Steps to reproduce (if applicable). +- Any relevant logs, crash reports, or proof of concept. +- A suggested fix or mitigation (optional but appreciated). + +You can expect a response **within 48 hours**, and we'll work with you to confirm and fix the issue as quickly as possible. + +--- + +## Disclosure Policy + +- Once a fix is ready, we’ll release an updated version of Railroad. +- You’ll be credited for the discovery if you wish. +- We generally aim to disclose details publicly **after the patch release**, unless there’s a reason to delay for ecosystem safety. + +--- + +## Security Best Practices for Plugin Developers + +If you're developing plugins for Railroad: +- **Never** execute remote code or download arbitrary files without explicit user consent. +- **Always** verify signatures or hashes for remote content. +- **Avoid** storing credentials in plain text — use Railroad’s secure storage API if available. +- **Do not** request unnecessary permissions. +- **Respect user privacy** — plugins must not track or collect personal data without consent. + +Plugins found violating these policies may be removed from the official plugin registry. + +--- + +## Scope + +This policy covers: +- Railroad IDE (core application) +- Railroad Plugin API +- Railroad Logger +- Switchboard service +- Official Railroad plugins + +If the vulnerability affects a dependency or external service, we’ll coordinate disclosure with the relevant maintainers. + +--- + +*Thank you for helping keep the Railroad ecosystem secure.* diff --git a/.gitignore b/.gitignore index 59f9c835..77c687e0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ +### Railroad ### +.railroad/ + ### NetBeans ### /nbproject/private/ /nbbuild/ @@ -47,4 +50,4 @@ bin/ *.class ### Logs ### -hs_err_pid*.log \ No newline at end of file +hs_err_pid*.log diff --git a/META-INF/gradle-plugins/dev.railroadide.railroadgradleplugin.properties b/META-INF/gradle-plugins/dev.railroadide.railroadgradleplugin.properties new file mode 100644 index 00000000..230df5dd --- /dev/null +++ b/META-INF/gradle-plugins/dev.railroadide.railroadgradleplugin.properties @@ -0,0 +1 @@ +implementation-class=dev.railroadide.railroadplugin.RailroadProjectPlugin diff --git a/build.gradle b/build.gradle index 4eec43bb..04765454 100644 --- a/build.gradle +++ b/build.gradle @@ -33,10 +33,18 @@ tasks.withType(JavaCompile).configureEach { application { mainClass = 'dev.railroadide.railroad.RailroadLauncher' applicationDefaultJvmArgs = [ - '-Djavafx.preloader=dev.railroadide.railroad.RailroadPreloader' + '-Djavafx.preloader=dev.railroadide.railroad.RailroadPreloader', + '--add-exports=javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED', + '--add-exports=javafx.graphics/com.sun.javafx.iio.common=ALL-UNNAMED' ] } +tasks.named("run") { + jvmArgs '-Djavafx.preloader=dev.railroadide.railroad.RailroadPreloader', + '--add-exports=javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED', + '--add-exports=javafx.graphics/com.sun.javafx.iio.common=ALL-UNNAMED' +} + javafx { version = '21' modules = ['javafx.controls', 'javafx.swing', 'javafx.web', 'javafx.fxml', 'javafx.graphics', 'javafx.media'] @@ -45,6 +53,9 @@ javafx { dependencies { implementation 'org.kordamp.ikonli:ikonli-javafx:12.4.0' implementation 'org.kordamp.ikonli:ikonli-fontawesome6-pack:12.4.0' + implementation 'org.kordamp.ikonli:ikonli-devicons-pack:12.4.0' + implementation 'org.kordamp.jipsy:jipsy-processor:1.2.0' + annotationProcessor 'org.kordamp.jipsy:jipsy-annotations:1.2.0' implementation 'org.jetbrains:annotations:26.0.2-1' implementation 'com.google.code.gson:gson:2.13.2' implementation 'com.squareup.okhttp3:okhttp:5.2.1' @@ -68,7 +79,9 @@ dependencies { implementation 'org.apache.commons:commons-text:1.14.0' implementation 'net.java.dev.jna:jna:5.18.1' implementation 'org.xerial:sqlite-jdbc:3.50.3.0' - + implementation 'com.github.numind-tech:javafxsvg:ce188ef95d' + implementation 'io.github.raghul-tech:javafx-markdown-preview-all:1.0.3' + implementation 'org.joml:joml:1.10.8' implementation 'com.google.guava:guava:33.5.0-jre' implementation 'org.apache.maven:maven-core:4.0.0-rc-4' implementation 'org.apache.maven:maven-settings:4.0.0-rc-4' @@ -76,8 +89,7 @@ dependencies { implementation 'org.apache.maven:maven-repository-metadata:4.0.0-rc-4' implementation 'org.codehaus.plexus:plexus-container-default:2.1.1' - implementation 'dev.railroadide:java-version-extractor-plugin:1.0.0' - implementation 'dev.railroadide:fabric-extractor-plugin:1.0.0' + implementation 'dev.railroadide:RailroadGradlePlugin:1.0.0' implementation 'io.get-coursier:interface:1.0.29-M1' implementation 'org.eclipse.jgit:org.eclipse.jgit:7.4.0.202509020913-r' @@ -130,7 +142,9 @@ runtime { launcher { jvmArgs = [ - '-Djavafx.preloader=dev.railroadide.railroad.RailroadPreloader' + '-Djavafx.preloader=dev.railroadide.railroad.RailroadPreloader', + '--add-exports=javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED', + '--add-exports=javafx.graphics/com.sun.javafx.iio.common=ALL-UNNAMED' ] } diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..065aa2f1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1759143472, + "narHash": "sha256-TvODmeR2W7yX/JmOCmP+lAFNkTT7hAxYcF3Kz8SZV3w=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "5ed4e25ab58fd4c028b59d5611e14ea64de51d23", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..940ae3c8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; + }; + + outputs = inputs: + let + javaVersion = 21; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forEachSystem = f: + inputs.nixpkgs.lib.genAttrs systems ( + system: + f { + pkgs = import inputs.nixpkgs { + inherit system; + overlays = [ inputs.self.overlays.default ]; + }; + } + ); + in + { + overlays.default = final: prev: + let + jdk = prev."jdk${toString javaVersion}"; + javafx = prev.openjfx; + in + { + inherit jdk javafx; + gradle = prev.gradle.override { java = jdk; }; + lombok = prev.lombok.override { inherit jdk; }; + }; + + devShells = forEachSystem ( + { pkgs }: + { + default = pkgs.mkShell { + packages = with pkgs; [ + gradle + jdk + javafx + + libGL + alsa-lib + + gtk3 + glib + gsettings-desktop-schemas + + xorg.libX11 + xorg.libXtst + xorg.libXi + xorg.libXxf86vm + ]; + + shellHook = + let + loadLombok = "-javaagent:${pkgs.lombok}/share/java/lombok.jar"; + prev = "\${JAVA_TOOL_OPTIONS:+ $JAVA_TOOL_OPTIONS}"; + in + '' + export JAVAFX_MODULE_PATH=${pkgs.javafx}/lib + export LD_LIBRARY_PATH="${pkgs.libGL}/lib:${pkgs.gtk3}/lib:${pkgs.glib.out}/lib:${pkgs.xorg.libX11}/lib:${pkgs.xorg.libXtst}/lib:${pkgs.xorg.libXi}/lib:${pkgs.xorg.libXxf86vm}/lib:${pkgs.alsa-lib}/lib:$LD_LIBRARY_PATH" + export JAVA_TOOL_OPTIONS="${loadLombok}${prev}" + export GSETTINGS_SCHEMA_DIR="${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}/glib-2.0/schemas" + + mkdir ~/.config/Railroad + touch ~/.config/Railroad/logger_config.json + ''; + }; + } + ); + }; +} diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/Form.java b/railroad-core/src/main/java/dev/railroadide/core/form/Form.java index 316ad487..5e733771 100644 --- a/railroad-core/src/main/java/dev/railroadide/core/form/Form.java +++ b/railroad-core/src/main/java/dev/railroadide/core/form/Form.java @@ -6,6 +6,7 @@ import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Button; +import lombok.Getter; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,6 +31,7 @@ public class Form { private final @Nullable Button resetButton; private final Pos buttonAlignment; + @Getter private final FormData formData = new FormData(); /** diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/FormComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/FormComponent.java index 3b689857..bccc4dd4 100644 --- a/railroad-core/src/main/java/dev/railroadide/core/form/FormComponent.java +++ b/railroad-core/src/main/java/dev/railroadide/core/form/FormComponent.java @@ -170,6 +170,29 @@ public static TextAreaComponent.Builder textArea(@NotNull String dataKey, @NotNu return new TextAreaComponent.Builder(dataKey, label); } + /** + * Creates a new file chooser component. + * + * @param dataKey The key of the data. + * @param label The label of the component. + * @return The builder for the file chooser component. + */ + public static FileChooserComponent.Builder fileChooser(@NotNull String dataKey, @NotNull String label) { + return new FileChooserComponent.Builder(dataKey, label); + } + + /** + * Creates a new radio button group component backed by an enum. + * + * @param dataKey The key of the data. + * @param label The label of the component. + * @param enumClass The enum class providing the options. + * @return The builder for the radio button group component. + */ + public static > RadioButtonGroupComponent.Builder radioButtonGroup(@NotNull String dataKey, @NotNull String label, @NotNull Class enumClass) { + return new RadioButtonGroupComponent.Builder<>(dataKey, label, enumClass); + } + /** * Returns a property that represents the default data of the component. * diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/impl/ComboBoxComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/impl/ComboBoxComponent.java index f6cfb87b..b81ef286 100644 --- a/railroad-core/src/main/java/dev/railroadide/core/form/impl/ComboBoxComponent.java +++ b/railroad-core/src/main/java/dev/railroadide/core/form/impl/ComboBoxComponent.java @@ -61,7 +61,7 @@ public class ComboBoxComponent extends FormComponent, ComboBo */ public ComboBoxComponent(String dataKey, Data data, FormComponentValidator> validator, FormComponentChangeListener, T> listener, Property> bindComboBoxTo, List, T, ?>> transformers, EventHandler keyTypedHandler, @Nullable BooleanBinding visible, Callback, ListCell> cellFactory, ListCell buttonCell, Supplier defaultValue) { super(dataKey, data, currentData -> { - var formComboBox = new FormComboBox<>(currentData.label, currentData.required, currentData.editable, currentData.translate, currentData.keyFunction, currentData.valueOfFunction); + var formComboBox = new FormComboBox<>(currentData.label, currentData.required, currentData.editable, currentData.translate, (T v) -> currentData.keyFunction.toString(v)); if (!currentData.translate) { formComboBox.getPrimaryComponent().setConverter(new ComboBoxConverter<>(currentData.keyFunction, currentData.valueOfFunction)); } diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/impl/DirectoryChooserComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/impl/DirectoryChooserComponent.java index bb5fd9a6..035da2e8 100644 --- a/railroad-core/src/main/java/dev/railroadide/core/form/impl/DirectoryChooserComponent.java +++ b/railroad-core/src/main/java/dev/railroadide/core/form/impl/DirectoryChooserComponent.java @@ -14,6 +14,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -153,6 +154,17 @@ public Builder defaultPath(@Nullable String defaultPath) { return this; } + /** + * Sets the default path for the directory chooser. + * + * @param defaultPath the default path for the directory chooser + * @return this builder + */ + public Builder defaultPath(@Nullable Path defaultPath) { + this.data.defaultPath(defaultPath != null ? defaultPath.toString() : null); + return this; + } + /** * Sets whether the directory chooser is required. * diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/impl/FileChooserComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/impl/FileChooserComponent.java new file mode 100644 index 00000000..8f550556 --- /dev/null +++ b/railroad-core/src/main/java/dev/railroadide/core/form/impl/FileChooserComponent.java @@ -0,0 +1,235 @@ +package dev.railroadide.core.form.impl; + +import dev.railroadide.core.form.*; +import dev.railroadide.core.form.ui.FormFileChooser; +import dev.railroadide.core.ui.BrowseButton; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.Property; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.control.TextField; +import javafx.scene.input.KeyEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * File picker counterpart to {@link DirectoryChooserComponent}. + */ +public class FileChooserComponent extends FormComponent { + public FileChooserComponent(String dataKey, + Data data, + FormComponentValidator validator, + FormComponentChangeListener listener, + Property bindTextFieldTo, + Property bindBrowseButtonTo, + List> transformers, + EventHandler keyTypedHandler, + @Nullable BooleanBinding visible) { + super(dataKey, data, d -> new FormFileChooser(d.label, d.required, d.defaultPath, d.includeButton), validator, listener, transformers, visible); + + if (bindTextFieldTo != null) { + bindTextFieldTo.bind(componentProperty().map(FormFileChooser::getPrimaryComponent).map(FormFileChooser.TextFieldWithButton::getTextField)); + } + + if (bindBrowseButtonTo != null) { + bindBrowseButtonTo.bind(componentProperty().map(FormFileChooser::getPrimaryComponent).map(FormFileChooser.TextFieldWithButton::getBrowseButton)); + } + + if (keyTypedHandler != null) { + componentProperty().get().getPrimaryComponent().addEventHandler(KeyEvent.KEY_TYPED, keyTypedHandler); + + componentProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.getPrimaryComponent().removeEventHandler(KeyEvent.KEY_TYPED, keyTypedHandler); + } + + if (newValue != null) { + newValue.getPrimaryComponent().addEventHandler(KeyEvent.KEY_TYPED, keyTypedHandler); + } + }); + } + } + + @Override + public ObservableValue getValidationNode() { + return componentProperty() + .map(FormFileChooser::getPrimaryComponent) + .map(FormFileChooser.TextFieldWithButton::getTextField); + } + + @Override + protected void applyListener(FormComponentChangeListener listener) { + AtomicReference> listenerRef = new AtomicReference<>(); + componentProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.getPrimaryComponent().getTextField().textProperty().removeListener(listenerRef.get()); + } + + if (newValue != null) { + ChangeListener changeListener = (observable1, oldValue1, newValue1) -> + listener.changed(newValue.getPrimaryComponent().getTextField(), observable1, oldValue1, newValue1); + listenerRef.set(changeListener); + newValue.getPrimaryComponent().getTextField().textProperty().addListener(changeListener); + } + }); + } + + @Override + protected void bindToFormData(FormData formData) { + componentProperty() + .map(FormFileChooser::getPrimaryComponent) + .map(FormFileChooser.TextFieldWithButton::getTextField) + .flatMap(TextField::textProperty) + .addListener((observable, oldValue, newValue) -> + formData.addProperty(dataKey, newValue)); + + formData.addProperty(dataKey, componentProperty() + .map(FormFileChooser::getPrimaryComponent) + .map(FormFileChooser.TextFieldWithButton::getTextField) + .map(TextField::getText) + .orElse(getData().defaultPath) + .getValue()); + } + + @Override + public void reset() { + getComponent().getPrimaryComponent().getTextField().setText(getData().defaultPath); + } + + public static class Builder implements FormComponentBuilder { + private final String dataKey; + private final Data data; + private final List> transformers = new ArrayList<>(); + private FormComponentValidator validator; + private FormComponentChangeListener listener; + private Property bindTextFieldTo; + private Property bindBrowseButtonTo; + private EventHandler keyTypedHandler; + private BooleanBinding visible; + + public Builder(@NotNull String dataKey, @NotNull String label) { + this.dataKey = dataKey; + this.data = new Data(label); + } + + @Override + public String dataKey() { + return dataKey; + } + + public Builder defaultPath(@Nullable String defaultPath) { + data.defaultPath(defaultPath); + return this; + } + + public Builder defaultPath(@Nullable Path defaultPath) { + data.defaultPath(defaultPath != null ? defaultPath.toString() : null); + return this; + } + + public Builder required(boolean required) { + data.required(required); + return this; + } + + public Builder required() { + return required(true); + } + + public Builder includeButton(boolean includeButton) { + data.includeButton(includeButton); + return this; + } + + public Builder bindTextFieldTo(Property bindTextFieldTo) { + this.bindTextFieldTo = bindTextFieldTo; + return this; + } + + public Builder bindBrowseButtonTo(Property bindBrowseButtonTo) { + this.bindBrowseButtonTo = bindBrowseButtonTo; + return this; + } + + @Override + public Builder validator(FormComponentValidator validator) { + this.validator = validator; + return this; + } + + @Override + public Builder listener(FormComponentChangeListener listener) { + this.listener = listener; + return this; + } + + @Override + public Builder addTransformer(ObservableValue fromComponent, Consumer toComponentFunction, Function valueMapper) { + transformers.add(new FormTransformer<>(fromComponent, TextField::getText, toComponentFunction, valueMapper)); + return this; + } + + @Override + public Builder addTransformer(ObservableValue fromComponent, ObservableValue toComponent, Function valueMapper) { + transformers.add(new FormTransformer<>(fromComponent, TextField::getText, value -> { + if (toComponent.getValue() instanceof TextField target) { + target.setText(value.toString()); + } else { + throw new IllegalArgumentException("Unsupported component type: " + toComponent.getValue().getClass().getName()); + } + }, valueMapper)); + return this; + } + + public Builder keyTypedHandler(EventHandler keyTypedHandler) { + this.keyTypedHandler = keyTypedHandler; + return this; + } + + @Override + public Builder visible(BooleanBinding visible) { + this.visible = visible; + return this; + } + + @Override + public FileChooserComponent build() { + return new FileChooserComponent(dataKey, data, validator, listener, bindTextFieldTo, bindBrowseButtonTo, transformers, keyTypedHandler, visible); + } + } + + public static class Data { + private final String label; + private String defaultPath; + private boolean required; + private boolean includeButton = true; + + public Data(@NotNull String label) { + this.label = label; + } + + public Data defaultPath(@Nullable String defaultPath) { + this.defaultPath = defaultPath; + return this; + } + + public Data required(boolean required) { + this.required = required; + return this; + } + + public Data includeButton(boolean includeButton) { + this.includeButton = includeButton; + return this; + } + } +} diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/impl/RadioButtonGroupComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/impl/RadioButtonGroupComponent.java new file mode 100644 index 00000000..b6867d67 --- /dev/null +++ b/railroad-core/src/main/java/dev/railroadide/core/form/impl/RadioButtonGroupComponent.java @@ -0,0 +1,289 @@ +package dev.railroadide.core.form.impl; + +import dev.railroadide.core.form.*; +import dev.railroadide.core.form.ui.FormRadioButtonGroup; +import dev.railroadide.core.utility.ServiceLocator; +import dev.railroadide.logger.Logger; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.scene.Node; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Form component that renders an enum as a radio button group. + * + * @param enum type + */ +public class RadioButtonGroupComponent> extends FormComponent, RadioButtonGroupComponent.Data, FormRadioButtonGroup, E> { + public RadioButtonGroupComponent(String dataKey, + Data data, + FormComponentValidator> validator, + FormComponentChangeListener, E> listener, + List, E, ?>> transformers, + @Nullable BooleanBinding visible) { + super(dataKey, data, currentData -> { + var component = new FormRadioButtonGroup<>( + currentData.label, + currentData.required, + currentData.options(), + currentData.optionLabelProvider, + currentData.translateOptions, + currentData.spacing + ); + + E defaultSelection = currentData.selectedSupplier.get(); + if (defaultSelection != null) { + component.setValue(defaultSelection); + } + + return component; + }, validator, listener, transformers, visible); + } + + @Override + public ObservableValue> getValidationNode() { + return componentProperty(); + } + + @Override + protected void applyListener(FormComponentChangeListener, E> listener) { + AtomicReference> listenerRef = new AtomicReference<>(); + componentProperty().addListener((observable, oldComponent, newComponent) -> { + if (oldComponent != null && listenerRef.get() != null) { + oldComponent.valueProperty().removeListener(listenerRef.get()); + } + + if (newComponent != null) { + ChangeListener changeListener = (obs, oldValue, newValue) -> + listener.changed(newComponent, obs, oldValue, newValue); + listenerRef.set(changeListener); + newComponent.valueProperty().addListener(changeListener); + } + }); + } + + @Override + protected void bindToFormData(FormData formData) { + AtomicReference> valueListener = new AtomicReference<>(); + + Runnable attachListener = () -> { + FormRadioButtonGroup component = componentProperty().get(); + if (component == null) { + return; + } + + ChangeListener changeListener = (observable, oldValue, newValue) -> + formData.add(dataKey, newValue); + valueListener.set(changeListener); + component.valueProperty().addListener(changeListener); + formData.add(dataKey, component.getValue()); + }; + + componentProperty().addListener((observable, oldComponent, newComponent) -> { + if (oldComponent != null && valueListener.get() != null) { + oldComponent.valueProperty().removeListener(valueListener.get()); + } + + attachListener.run(); + }); + + attachListener.run(); + } + + @Override + public void reset() { + E selection = getData().selectedSupplier.get(); + getComponent().setValue(selection); + } + + /** + * Builder for {@link RadioButtonGroupComponent}. + */ + public static class Builder> implements FormComponentBuilder, FormRadioButtonGroup, E, Builder> { + private final String dataKey; + private final Data data; + private final List, E, ?>> transformers = new ArrayList<>(); + private FormComponentValidator> validator; + private FormComponentChangeListener, E> listener; + private BooleanBinding visible; + + public Builder(@NotNull String dataKey, @NotNull String label, @NotNull Class enumClass) { + this.dataKey = dataKey; + this.data = new Data<>(label, enumClass); + deriveLocalizationDefaults(enumClass); + } + + private void deriveLocalizationDefaults(Class enumClass) { + try { + Method method = enumClass.getMethod("getLocalizationKey"); + if (method.getReturnType() == String.class) { + data.optionLabelProvider(enumValue -> { + try { + return (String) method.invoke(enumValue); + } catch (IllegalAccessException | InvocationTargetException exception) { + ServiceLocator.getService(Logger.class).warn("Failed to invoke getLocalizationKey for {}", enumValue, exception); + return enumValue.name(); + } + }); + data.translateOptions(true); + return; + } + } catch (NoSuchMethodException ignored) { + // Default fallback handled below. + } + + data.optionLabelProvider(Enum::name); + } + + @Override + public String dataKey() { + return dataKey; + } + + public Builder options(List options) { + data.options(options); + return this; + } + + public Builder options(E[] options) { + return options(Arrays.asList(options)); + } + + public Builder required(boolean required) { + data.required(required); + return this; + } + + public Builder required() { + return required(true); + } + + public Builder selected(E selected) { + return selected(() -> selected); + } + + public Builder selected(Supplier supplier) { + data.selectedSupplier(Objects.requireNonNull(supplier)); + return this; + } + + public Builder spacing(double spacing) { + data.spacing(spacing); + return this; + } + + public Builder optionLabelProvider(Function provider) { + data.optionLabelProvider(provider); + return this; + } + + public Builder translateOptions(boolean translate) { + data.translateOptions(translate); + return this; + } + + @Override + public Builder validator(FormComponentValidator> validator) { + this.validator = validator; + return this; + } + + @Override + public Builder listener(FormComponentChangeListener, E> listener) { + this.listener = listener; + return this; + } + + @Override + public Builder addTransformer(ObservableValue> fromComponent, Consumer toComponentFunction, Function valueMapper) { + transformers.add(new FormTransformer<>(fromComponent, FormRadioButtonGroup::getValue, toComponentFunction, valueMapper)); + return this; + } + + @Override + public Builder addTransformer(ObservableValue> fromComponent, ObservableValue toComponent, Function valueMapper) { + transformers.add(new FormTransformer<>(fromComponent, FormRadioButtonGroup::getValue, value -> { + Node target = toComponent.getValue(); + if (target instanceof HasSetValue hasSetValue) { + hasSetValue.setValue(value); + } + }, valueMapper)); + return this; + } + + @Override + public Builder visible(BooleanBinding visible) { + this.visible = visible; + return this; + } + + @Override + public RadioButtonGroupComponent build() { + return new RadioButtonGroupComponent<>(dataKey, data, validator, listener, transformers, visible); + } + } + + /** + * Mutable data backing the component factory. + */ + public static class Data> { + private final String label; + private final Class enumClass; + private boolean required; + private boolean translateOptions; + private double spacing = 12; + private Supplier selectedSupplier = () -> null; + private Function optionLabelProvider = Enum::name; + private List options; + + public Data(String label, Class enumClass) { + this.label = label; + this.enumClass = enumClass; + this.options = enumClass.isEnum() ? List.of(enumClass.getEnumConstants()) : List.of(); + } + + public void required(boolean required) { + this.required = required; + } + + public void translateOptions(boolean translateOptions) { + this.translateOptions = translateOptions; + } + + public void spacing(double spacing) { + this.spacing = spacing; + } + + public void selectedSupplier(Supplier selectedSupplier) { + this.selectedSupplier = Objects.requireNonNull(selectedSupplier); + } + + public void optionLabelProvider(Function optionLabelProvider) { + this.optionLabelProvider = Objects.requireNonNull(optionLabelProvider); + } + + public void options(List options) { + this.options = List.copyOf(Objects.requireNonNull(options)); + } + + public List options() { + if (options != null) + return options; + + return List.of(enumClass.getEnumConstants()); + } + } +} diff --git a/railroad-core/src/main/java/dev/railroadide/core/form/impl/TextAreaComponent.java b/railroad-core/src/main/java/dev/railroadide/core/form/impl/TextAreaComponent.java index 52a9b773..0027efdd 100644 --- a/railroad-core/src/main/java/dev/railroadide/core/form/impl/TextAreaComponent.java +++ b/railroad-core/src/main/java/dev/railroadide/core/form/impl/TextAreaComponent.java @@ -18,6 +18,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; /** * A form component that represents a text area. @@ -41,7 +42,10 @@ public class TextAreaComponent extends FormComponent validator, FormComponentChangeListener listener, Property