diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java new file mode 100644 index 0000000..5d84696 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java @@ -0,0 +1,49 @@ +package de.neitzel.fx.view; + +import javafx.beans.DefaultProperty; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * The list of binding elements declared inside {@code fx:define}. + *

+ * The list is not a scene-graph child. FXML adds each {@link DirectBinding} child to + * {@link #getElements()}. + */ +@DefaultProperty("elements") +public class BindingList { + + private final List elements = new ArrayList<>(); + + /** + * Creates a list and registers it with the load in progress. + */ + public BindingList() { + LoadSession.register(this); + } + + /** + * Returns the Direct bindings in this list. + *

+ * FXML appends child elements to the returned list. + * + * @return the mutable list of Direct bindings + */ + public List getElements() { + return elements; + } + + /** + * Applies every Direct binding in this list. + * + * @param namespace objects addressed by {@code fx:id} + * @param viewModel the ViewModel for this View + */ + void apply(Map namespace, ViewModel viewModel) { + for (DirectBinding binding : elements) { + DirectBindingApplier.apply(namespace, viewModel, binding); + } + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java new file mode 100644 index 0000000..9938b32 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBinding.java @@ -0,0 +1,88 @@ +package de.neitzel.fx.view; + +/** + * A Direct binding declared in FXML. + *

+ * The element names the control by {@code fx:id}, the control property, the Path, and the + * direction. A Path of one name is that Attribute of this View's ViewModel. Direction values + * are {@code view}, {@code model}, and {@code both}. + */ +public class DirectBinding { + + private String control; + private String property; + private String path; + private String direction; + + /** + * Sets the {@code fx:id} of the control. + * + * @param control the control id + */ + public void setControl(String control) { + this.control = control; + } + + /** + * Sets the control property name, such as {@code text}. + * + * @param property the property name + */ + public void setProperty(String property) { + this.property = property; + } + + /** + * Sets the Path. One name is an Attribute of this View's ViewModel. + * + * @param path the path + */ + public void setPath(String path) { + this.path = path; + } + + /** + * Sets the direction. Accepted values are {@code view}, {@code model}, and {@code both}. + * + * @param direction the direction value + */ + public void setDirection(String direction) { + this.direction = direction; + } + + /** + * Returns the {@code fx:id} of the control. + * + * @return the control id + */ + public String getControl() { + return control; + } + + /** + * Returns the control property name. + * + * @return the property name + */ + public String getProperty() { + return property; + } + + /** + * Returns the Path. + * + * @return the path + */ + public String getPath() { + return path; + } + + /** + * Returns the direction value. + * + * @return {@code view}, {@code model}, or {@code both} + */ + public String getDirection() { + return direction; + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java new file mode 100644 index 0000000..73d170c --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java @@ -0,0 +1,80 @@ +package de.neitzel.fx.view; + +import javafx.beans.property.Property; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Connects one Direct binding to a control property and a ViewModel Attribute. + */ +final class DirectBindingApplier { + + private DirectBindingApplier() { + } + + /** + * Applies one Direct binding. + * + * @param namespace objects addressed by {@code fx:id} + * @param viewModel the ViewModel for this View + * @param binding the declaration + */ + static void apply(Map namespace, ViewModel viewModel, DirectBinding binding) { + Object control = namespace.get(binding.getControl()); + if (control == null) { + throw new IllegalArgumentException("No control \"" + binding.getControl() + "\""); + } + Property controlProperty = controlProperty(control, binding.getProperty()); + Property attribute = viewModel.property(binding.getPath()); + if (attribute == null) { + throw new IllegalArgumentException( + "No read-write attribute for path \"" + binding.getPath() + "\""); + } + switch (binding.getDirection()) { + case "both" -> alignBoth(controlProperty, attribute); + case "view" -> alignTowardView(controlProperty, attribute); + case "model" -> alignTowardModel(controlProperty, attribute); + default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection()); + } + } + + private static Property controlProperty(Object control, String propertyName) { + String methodName = propertyName + "Property"; + try { + Method method = control.getClass().getMethod(methodName); + Object value = method.invoke(control); + if (!(value instanceof Property property)) { + throw new IllegalArgumentException(methodName + " did not return a property"); + } + return property; + } catch (NoSuchMethodException failure) { + throw new IllegalArgumentException( + "No property " + propertyName + " on " + control.getClass().getName(), failure); + } catch (IllegalAccessException | InvocationTargetException failure) { + throw new IllegalStateException("Could not read property " + propertyName, failure); + } + } + + @SuppressWarnings("unchecked") + private static void alignBoth(Property controlProperty, Property attribute) { + ((Property) controlProperty).bindBidirectional((Property) attribute); + } + + @SuppressWarnings("unchecked") + private static void alignTowardView(Property controlProperty, Property attribute) { + Property control = (Property) controlProperty; + Property source = (Property) attribute; + control.setValue(source.getValue()); + source.addListener((observable, oldValue, newValue) -> control.setValue(newValue)); + } + + @SuppressWarnings("unchecked") + private static void alignTowardModel(Property controlProperty, Property attribute) { + Property control = (Property) controlProperty; + Property target = (Property) attribute; + control.setValue(target.getValue()); + control.addListener((observable, oldValue, newValue) -> target.setValue(newValue)); + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java new file mode 100644 index 0000000..33ea106 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java @@ -0,0 +1,60 @@ +package de.neitzel.fx.view; + +import java.util.ArrayList; +import java.util.List; + +/** + * Holds the binding lists instantiated while one View is loading. + *

+ * FXML objects declared in {@code fx:define} are not scene-graph children and have no + * {@code fx:id} in the accepted document shape, so each {@link BindingList} registers + * itself here from its constructor. + */ +final class LoadSession { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final List bindingLists = new ArrayList<>(); + + private LoadSession() { + } + + /** + * Starts a session on this thread. + * + * @return the open session + */ + static LoadSession open() { + LoadSession session = new LoadSession(); + CURRENT.set(session); + return session; + } + + /** + * Drops the session for this thread. + */ + static void close() { + CURRENT.remove(); + } + + /** + * Registers a binding list created while a session is open. + * + * @param bindingList the list FXML just constructed + */ + static void register(BindingList bindingList) { + LoadSession session = CURRENT.get(); + if (session != null) { + session.bindingLists.add(bindingList); + } + } + + /** + * Returns the lists registered during this session, in document order. + * + * @return the binding lists + */ + List bindingLists() { + return bindingLists; + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java new file mode 100644 index 0000000..25e394d --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java @@ -0,0 +1,54 @@ +package de.neitzel.fx.view; + +import javafx.beans.property.Property; +import javafx.scene.Parent; + +/** + * The scene-graph root and ViewModel produced by loading a View for a Model instance. + */ +public final class LoadedView { + + private final Parent root; + private final ViewModel viewModel; + + /** + * Creates the result of one load. + * + * @param root the scene-graph root + * @param viewModel the ViewModel built for the Model instance + */ + LoadedView(Parent root, ViewModel viewModel) { + this.root = root; + this.viewModel = viewModel; + } + + /** + * Returns the scene-graph root. + * + * @return the root + */ + public Parent getRoot() { + return root; + } + + /** + * Returns the ViewModel the View is bound to. + * + * @return the ViewModel + */ + public ViewModel getViewModel() { + return viewModel; + } + + /** + * Returns the JavaFX property for an Attribute name. + *

+ * The property's class follows the Attribute's declared type. + * + * @param name the Attribute name + * @return the property, or {@code null} when that name is not a read-write Attribute + */ + public Property property(String name) { + return viewModel.property(name); + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java new file mode 100644 index 0000000..4189a94 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -0,0 +1,59 @@ +package de.neitzel.fx.view; + +import de.neitzel.injection.ApplicationContext; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; + +import java.io.IOException; +import java.net.URL; + +/** + * Loads a View for one Model instance. + */ +public final class ViewLoader { + + private ViewLoader() { + } + + /** + * Loads a View for one Model instance. + *

+ * Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings + * declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute + * aligned, so editing the control writes the Attribute immediately. Direction {@code view} + * shows the Attribute and follows later property changes without writing the Attribute back. + * Direction {@code model} shows the current value, writes the Attribute when the control + * changes, and does not push later property changes to the control. A path that is not a + * read-write Attribute fails the load. The context is used when the document names a Controller. + * + * @param fxml the FXML document + * @param model the Model instance + * @param context the application context + * @return the loaded View + * @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute + */ + public static LoadedView load(URL fxml, Object model, ApplicationContext context) { + ViewModel viewModel = new ViewModel(model); + LoadSession session = LoadSession.open(); + try { + FXMLLoader loader = new FXMLLoader(fxml); + loader.setControllerFactory(type -> controller(context, type)); + Object loaded = loader.load(); + if (!(loaded instanceof Parent root)) { + throw new IllegalArgumentException("View root must be a parent"); + } + for (BindingList bindingList : session.bindingLists()) { + bindingList.apply(loader.getNamespace(), viewModel); + } + return new LoadedView(root, viewModel); + } catch (IOException failure) { + throw new IllegalStateException("Could not load view " + fxml, failure); + } finally { + LoadSession.close(); + } + } + + private static Object controller(ApplicationContext context, Class type) { + return context.getComponent(type.asSubclass(Object.class)); + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java new file mode 100644 index 0000000..1e108a1 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -0,0 +1,163 @@ +package de.neitzel.fx.view; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.FloatProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.LongProperty; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleFloatProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleLongProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; + +import java.beans.BeanInfo; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * The object a View binds to, built at runtime for one Model instance. + *

+ * Each read-write Attribute that is not declared on {@link Object} is stored as a JavaFX + * property under the Attribute name. The property class follows the Attribute's declared type, + * including when the current value is null. String, integer, long, float, double, and boolean + * attributes use the matching JavaFX property. Any other declared type is an object property. + * The property is set from the Attribute when the ViewModel is created. A null number or + * boolean leaves the primitive property at its default. After that, a change of the property + * writes the Attribute. A call to the entity's setter does not update the property. + */ +public class ViewModel { + + private final Object model; + private final Map> properties = new HashMap<>(); + + /** + * Builds a ViewModel for one Model instance. + * + * @param model the Model instance + */ + ViewModel(Object model) { + this.model = model; + for (PropertyDescriptor descriptor : descriptors(model.getClass())) { + Method getter = descriptor.getReadMethod(); + Method setter = descriptor.getWriteMethod(); + if (getter == null || setter == null || descriptor.getPropertyType() == null) { + continue; + } + String name = descriptor.getName(); + Property property = createProperty(descriptor.getPropertyType(), read(getter, name)); + watch(property, setter, name); + properties.put(name, property); + } + } + + /** + * Returns the JavaFX property for an Attribute name. + * + * @param name the Attribute name + * @return the property, or {@code null} when that name is not a read-write Attribute + */ + public Property property(String name) { + return properties.get(name); + } + + private static List descriptors(Class type) { + try { + BeanInfo info = Introspector.getBeanInfo(type, Object.class); + return List.of(info.getPropertyDescriptors()); + } catch (IntrospectionException failure) { + throw new IllegalStateException("Could not introspect " + type.getName(), failure); + } + } + + private static Property createProperty(Class type, Object value) { + if (type == String.class) { + return new SimpleStringProperty((String) value); + } + if (type == Integer.class || type == int.class) { + return integerProperty(value); + } + if (type == Long.class || type == long.class) { + return longProperty(value); + } + if (type == Float.class || type == float.class) { + return floatProperty(value); + } + if (type == Double.class || type == double.class) { + return doubleProperty(value); + } + if (type == Boolean.class || type == boolean.class) { + return booleanProperty(value); + } + return new SimpleObjectProperty<>(value); + } + + private static IntegerProperty integerProperty(Object value) { + SimpleIntegerProperty property = new SimpleIntegerProperty(); + if (value != null) { + property.setValue((Integer) value); + } + return property; + } + + private static LongProperty longProperty(Object value) { + SimpleLongProperty property = new SimpleLongProperty(); + if (value != null) { + property.setValue((Long) value); + } + return property; + } + + private static FloatProperty floatProperty(Object value) { + SimpleFloatProperty property = new SimpleFloatProperty(); + if (value != null) { + property.setValue((Float) value); + } + return property; + } + + private static DoubleProperty doubleProperty(Object value) { + SimpleDoubleProperty property = new SimpleDoubleProperty(); + if (value != null) { + property.setValue((Double) value); + } + return property; + } + + private static BooleanProperty booleanProperty(Object value) { + SimpleBooleanProperty property = new SimpleBooleanProperty(); + if (value != null) { + property.setValue((Boolean) value); + } + return property; + } + + private Object read(Method getter, String name) { + try { + return getter.invoke(model); + } catch (IllegalAccessException | InvocationTargetException failure) { + throw new IllegalStateException("Could not read attribute " + name, failure); + } + } + + private void watch(Property property, Method setter, String name) { + property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue)); + } + + private void write(Method setter, String name, Object value) { + try { + setter.invoke(model, value); + } catch (IllegalAccessException | InvocationTargetException failure) { + throw new IllegalStateException("Could not write attribute " + name, failure); + } + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java b/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java new file mode 100644 index 0000000..d65b151 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/ShowAndEditOneAttributeTest.java @@ -0,0 +1,160 @@ +package de.neitzel.fx.view; + +import de.neitzel.fx.view.fixture.Person; +import de.neitzel.injection.ApplicationContext; +import javafx.application.Platform; +import javafx.beans.property.IntegerProperty; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class ShowAndEditOneAttributeTest { + + @BeforeAll + static void startJavaFx() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + try { + Platform.startup(started::countDown); + } catch (IllegalStateException alreadyStarted) { + started.countDown(); + } + if (!started.await(10, TimeUnit.SECONDS)) { + fail("JavaFX toolkit did not start"); + } + } + + @Test + void bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField nameField = (TextField) loaded.getRoot().lookup("#nameField"); + assertEquals("Ada", nameField.getText()); + nameField.setText("Grace"); + assertEquals("Grace", person.getName()); + }); + } + + @Test + void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-and-view.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + TextField nameField = (TextField) loaded.getRoot().lookup("#nameField"); + Label nameLabel = (Label) loaded.getRoot().lookup("#nameLabel"); + assertEquals("Ada", nameLabel.getText()); + nameField.setText("Grace"); + assertEquals("Grace", nameLabel.getText()); + assertEquals("Grace", person.getName()); + nameLabel.setText("Hopper"); + assertEquals("Grace", person.getName()); + }); + } + + @Test + void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + person.setName("Grace"); + TextField nameField = (TextField) loaded.getRoot().lookup("#nameField"); + assertEquals("Ada", nameField.getText()); + }); + } + + @Test + void bindingAReadOnlyGetterFailsTheLoad() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("read-only-title.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> ViewLoader.load(fxml, person, context)); + assertTrue(failure.getMessage().contains("title")); + }); + } + + @Test + void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("object-class.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> ViewLoader.load(fxml, person, context)); + assertTrue(failure.getMessage().contains("class")); + }); + } + + @Test + void nullIntegerAttributeUsesAnIntegerProperty() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + assertInstanceOf(IntegerProperty.class, loaded.property("age")); + }); + } + + private static void onFx(FxAction action) throws Exception { + if (Platform.isFxApplicationThread()) { + action.run(); + return; + } + AtomicReference thrown = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + Platform.runLater(() -> { + try { + action.run(); + } catch (Throwable failure) { + thrown.set(failure); + } finally { + done.countDown(); + } + }); + if (!done.await(10, TimeUnit.SECONDS)) { + fail("JavaFX thread did not finish"); + } + Throwable failure = thrown.get(); + if (failure instanceof Exception exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new AssertionError(failure); + } + } + + @FunctionalInterface + private interface FxAction { + void run() throws Exception; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java new file mode 100644 index 0000000..12cfd17 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java @@ -0,0 +1,64 @@ +package de.neitzel.fx.view.fixture; + +/** + * Ordinary model used by view-loading tests. + */ +public class Person { + + private String name; + private Integer age; + + /** + * Creates a person with the given name. + * + * @param name the current name + */ + public Person(String name) { + this.name = name; + } + + /** + * Returns the name. + * + * @return the name + */ + public String getName() { + return name; + } + + /** + * Sets the name. + * + * @param name the name + */ + public void setName(String name) { + this.name = name; + } + + /** + * Returns a title that cannot be written. + * + * @return the title + */ + public String getTitle() { + return "Doctor"; + } + + /** + * Returns the age. The value may be null. + * + * @return the age + */ + public Integer getAge() { + return age; + } + + /** + * Sets the age. + * + * @param age the age + */ + public void setAge(Integer age) { + this.age = age; + } +} diff --git a/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml b/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml new file mode 100644 index 0000000..8b16ddd --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/name-both-and-view.fxml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml b/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml new file mode 100644 index 0000000..18f18ff --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/name-both-ways.fxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml b/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml new file mode 100644 index 0000000..df98c9c --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/object-class.fxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml b/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml new file mode 100644 index 0000000..014044f --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/read-only-title.fxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +