diff --git a/fxcomps/LICENSE.md b/fxcomps/LICENSE.md new file mode 100644 index 00000000..ce0fe7e8 --- /dev/null +++ b/fxcomps/LICENSE.md @@ -0,0 +1,21 @@ +### MIT License + +Copyright (c) 2021 Christopher Schnick + +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/fxcomps/README.md b/fxcomps/README.md new file mode 100644 index 00000000..17e427a4 --- /dev/null +++ b/fxcomps/README.md @@ -0,0 +1,139 @@ +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.xpipe/fxcomps/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.xpipe/fxcomps) +[![javadoc](https://javadoc.io/badge2/io.xpipe/fxcomps/javadoc.svg)](https://javadoc.io/doc/io.xpipe/fxcomps) +[![Build Status](https://github.com/xpipe-io/fxcomps/actions/workflows/publish.yml/badge.svg)](https://github.com/xpipe-io/fxcomps/actions/workflows/publish.yml) + +# FxComps - Compound Components for JavaFX + +The FxComps library provides a new approach to creating JavaFX interfaces and +offers a quicker and more robust user interface development workflow. +This library is compatible and can be used with any other JavaFX library. + +## Principles + +#### A comp is a Node/Region factory, not just another fancy wrapper for existing classes + +It is advantageous to define a certain component to be a factory +that can create an instances of a JavaFX Node each time it is called. +By using this factory architecture, the scene contents can +be rebuilt entirely by invoking the root component factory. +See the [hot reload](#Hot-Reload) section on how this can be used. + +Of course, if a component is a compound component that has children, +the parent factory has to incorporate the child factories into its creation process. +This can be done in fxcomps. + +#### A comp should produce a transparent representation of Regions and Controls + +In JavaFX, using skins allows for flexibility when generating the look and feel for a control. +One limitation of this approach is that the generated node tree is not very transparent +for developers who are especially interested in styling it. +This is caused by the fact that a skin does not expose the information required to style +it completely or even alter it without creating a new Skin class. + +A comp should be designed to allow developers to easily expose as much information +about the produced node tree structure using the CompStructure class. +In case you don't want to expose the detailed structure of your comp, +you can also just use a very simple structure. + +#### A comp should produce a Region instead of a Node + +In practice, working with the very abstract node class comes with its fair share of limitations. +It is much easier to work with region instances, as they have various width and height properties. +Since pretty much every Node is also a Region, the main focus of comps are regions. +In case you are dealing with Nodes that are not Regions, like an ImageView or WebView, +you can still wrap them inside for example a StackPane to obtain a Region again that you can work with. + +#### The generation process of a comp can be augmented + +As comps are factories, any changes that should be applied to all produced +Node instances must be integrated into the factory pipeline. +This can be achieved with the Augment class, which allows you +to alter the produced node after the base factory has finished. + +#### Properties used by Comps should be managed by the user, not the Comp itself + +This allows Comps to only be a thin wrapper around already existing +Observables/Properties and gives the user the ability to complete control the handling of Properties. +This approach is also required for the next point. + +#### A comp should not break when used Observables are updated from a thread that is not the platform thread + +One common limitation of using JavaFX is that many things break when +calling any method from another thread that is not the platform thread. +While in many cases these issues can be mitigated by wrapping a problematic call in a Platform.runLater(...), +some problematic instances are harder to fix, for example Observable bindings. +In JavaFX, there is currently no way to propagate changes of an Observable +to other bound Observables only using the platform thread, when the original change was made from a different thread. +The FxComps library provides a solution with the PlatformThread.sync(...) methods and strongly encourages that +Comps make use of these methods in combination with user-managed properties +to allow for value changes for Observables from any thread without issue. + +## Hot reload + +The reason a Comp is designed to be a factory is to allow for hot +reloading your created GUI in conjunction with the hot-reload functionality in your IDE: + +````java + void setupReload(Scene scene, Comp content) { + var contentR = content.createRegion(); + scene.addEventHandler(KeyEvent.KEY_PRESSED, event -> { + if (event.getCode().equals(KeyCode.F5)) { + var newContentR = content.createRegion(); + scene.setRoot(newContentR); + event.consume(); + } + }); + } +```` + +If you for example bind your IDE Hot Reload to F4 and your Scene reload listener to F5, +you can almost instantly apply any changes made to your GUI code without restarting. +You can also implement a similar solution to also reload your stylesheets and translations. + +## Library contents + +Aside from the base classes needed to implement the principles listed above, +this library also comes with a few very basic Comp implementations and some Augments. +These are very general implementations and can be seen as example implementations. + +#### Comps + +- [HorizontalComp](src/main/java/io/xpipe/fxcomps/comp/HorizontalComp.java) / + [VerticalComp](src/main/java/io/xpipe/fxcomps/comp/VerticalComp.java): Simple Comp implementation to create a + HBox/VBox using Comps as input +- [StackComp](src/main/java/io/xpipe/fxcomps/comp/StackComp.java): Simple Comp implementation to easily create a stack + pane using Comps as input +- [StackComp](src/main/java/io/xpipe/fxcomps/comp/LabelComp.java): Simple Comp implementation for a label + +#### Augments + +- [GrowAugment](src/main/java/io/xpipe/fxcomps/augment/GrowAugment.java): Binds the width/height of a Comp to its + parent, adjusted for parent padding +- [PopupMenuComp](src/main/java/io/xpipe/fxcomps/augment/PopupMenuAugment.java): Allows you to show a context menu when + a comp is left-clicked in addition to right-click + +## Creating a basic comp + +As the central idea of this library is that you create your own Comps, it is designed to be very simple: + +````java + var b = Comp.of(() -> new Button("Button")); + var l = Comp.of(() -> new Label("Label")); + + // Create an HBox factory and apply some Augments to it + var layoutFactory = new HorizontalComp(List.of(b, l)) + .apply(struc -> struc.get().setAlignment(Pos.CENTER)) + .apply(GrowAugment.create(true, true)) + .styleClass("layout"); + + // You can now create node instances of your layout + var region = layoutFactory.createRegion(); +```` + +Most simple Comp definitions can be defined inline with the `Comp.of(...)` method. + +## Creating more complex comps + +For actual comp implementations, see for example +the [X-Pipe Extension API](https://github.com/xpipe-io/xpipe_java/tree/master/extension/src/main/java/io/xpipe/extension/comp) +. diff --git a/fxcomps/build.gradle b/fxcomps/build.gradle new file mode 100644 index 00000000..1a30a4d5 --- /dev/null +++ b/fxcomps/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'signing' +} + +version = file('../misc/version').text +group = 'io.xpipe' +archivesBaseName = 'xpipe-fxcomps' + +repositories { + mavenCentral() +} + +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform + +def currentOS = DefaultNativePlatform.currentOperatingSystem; +def platform +if (currentOS.isWindows()) { + platform = 'win' +} else if (currentOS.isLinux()) { + platform = 'linux' +} else if (currentOS.isMacOsX()) { + platform = 'mac' +} + +dependencies { + compileOnly "org.openjfx:javafx-base:18:${platform}" + compileOnly "org.openjfx:javafx-controls:18:${platform}" + compileOnly "org.openjfx:javafx-graphics:18:${platform}" + compileOnly "org.openjfx:javafx-media:18:${platform}" + compileOnly "org.openjfx:javafx-web:18:${platform}" + + compileOnly 'org.projectlombok:lombok:1.18.24' + annotationProcessor 'org.projectlombok:lombok:1.18.24' +} + +apply from: 'publish.gradle' +apply from: "$projectDir/../deps/publish-base.gradle" diff --git a/fxcomps/publish.gradle b/fxcomps/publish.gradle new file mode 100644 index 00000000..ff959d15 --- /dev/null +++ b/fxcomps/publish.gradle @@ -0,0 +1,33 @@ +publishing { + publications { + mavenJava(MavenPublication) { + artifactId = project.archivesBaseName + + from components.java + + pom { + name = 'FxComps' + description = 'The FxComps library provides a new approach to creating JavaFX interfaces and offers a quicker and more robust user interface development workflow.' + url = 'https://github.com/xpipe-io/xpipe_java/fxcomps' + licenses { + license { + name = 'The MIT License (MIT)' + url = 'https://github.com/xpipe-io/xpipe_java/LICENSE.md' + } + } + developers { + developer { + id = 'crschnick' + name = 'Christopher Schnick' + email = 'crschnick@xpipe.io' + } + } + scm { + connection = 'scm:git:git://github.com/xpipe-io/xpipe_java.git' + developerConnection = 'scm:git:ssh://github.com/xpipe-io/xpipe_java.git' + url = 'https://github.com/xpipe-io/xpipe_java' + } + } + } + } +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/Comp.java b/fxcomps/src/main/java/io/xpipe/fxcomps/Comp.java new file mode 100644 index 00000000..1892ca97 --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/Comp.java @@ -0,0 +1,104 @@ +package io.xpipe.fxcomps; + +import io.xpipe.fxcomps.augment.Augment; +import io.xpipe.fxcomps.augment.GrowAugment; +import io.xpipe.fxcomps.comp.WrapperComp; +import io.xpipe.fxcomps.util.Shortcuts; +import io.xpipe.fxcomps.util.SimpleChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyCombination; +import javafx.scene.layout.Region; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +public abstract class Comp> { + + private List> augments; + + public static Comp> of(Supplier r) { + return new WrapperComp<>(() -> { + var region = r.get(); + return () -> region; + }); + } + + public static > Comp ofStructure(Supplier r) { + return new WrapperComp<>(r); + } + + @SuppressWarnings("unchecked") + public static , OR extends Region> Comp> derive( + Comp comp, Function r) { + return of(() -> r.apply((IR) comp.createRegion())); + } + + @SuppressWarnings("unchecked") + public > T apply(Augment augment) { + if (augments == null) { + augments = new ArrayList<>(); + } + augments.add(augment); + return (T) this; + } + + public Comp disable(ObservableValue o) { + return apply(struc -> struc.get().disableProperty().bind(o)); + } + + public Comp hide(ObservableValue o) { + return apply(struc -> { + var region = struc.get(); + SimpleChangeListener.apply(o, n -> { + if (!n) { + region.setVisible(true); + region.setManaged(true); + } else { + region.setVisible(false); + region.setManaged(false); + } + }); + }); + } + + public Comp styleClass(String styleClass) { + return apply(struc -> struc.get().getStyleClass().add(styleClass)); + } + + public Comp grow(boolean width, boolean height) { + return apply(GrowAugment.create(false, false)); + } + + public Comp shortcut(KeyCombination shortcut, Consumer con) { + return apply(struc -> Shortcuts.addShortcut(struc.get(), shortcut, r -> con.accept(struc))); + } + + public Comp shortcut(KeyCombination shortcut) { + return apply(struc -> Shortcuts.addShortcut((ButtonBase) struc.get(), shortcut)); + } + + public Comp tooltip(Supplier text) { + return apply(r -> Tooltip.install(r.get(), new Tooltip(text.get()))); + } + + public Region createRegion() { + return createStructure().get(); + } + + public S createStructure() { + S struc = createBase(); + if (augments != null) { + for (var a : augments) { + a.augment(struc); + } + } + return struc; + } + + public abstract S createBase(); +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/CompStructure.java b/fxcomps/src/main/java/io/xpipe/fxcomps/CompStructure.java new file mode 100644 index 00000000..9f1d15d6 --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/CompStructure.java @@ -0,0 +1,7 @@ +package io.xpipe.fxcomps; + +import javafx.scene.layout.Region; + +public interface CompStructure { + R get(); +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleComp.java b/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleComp.java new file mode 100644 index 00000000..6a84613b --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleComp.java @@ -0,0 +1,13 @@ +package io.xpipe.fxcomps; + +import javafx.scene.layout.Region; + +public abstract class SimpleComp extends Comp> { + + @Override + public final CompStructure createBase() { + return new SimpleCompStructure<>(createSimple()); + } + + protected abstract Region createSimple(); +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleCompStructure.java b/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleCompStructure.java new file mode 100644 index 00000000..615410e6 --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/SimpleCompStructure.java @@ -0,0 +1,17 @@ +package io.xpipe.fxcomps; + +import javafx.scene.layout.Region; +import lombok.AllArgsConstructor; +import lombok.Value; + +@Value +@AllArgsConstructor +public class SimpleCompStructure implements CompStructure { + + R value; + + @Override + public R get() { + return value; + } +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/augment/Augment.java b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/Augment.java new file mode 100644 index 00000000..50bc3833 --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/Augment.java @@ -0,0 +1,8 @@ +package io.xpipe.fxcomps.augment; + +import io.xpipe.fxcomps.CompStructure; + +public interface Augment> { + + void augment(S struc); +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/augment/GrowAugment.java b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/GrowAugment.java new file mode 100644 index 00000000..d3bd79fe --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/GrowAugment.java @@ -0,0 +1,65 @@ +package io.xpipe.fxcomps.augment; + +import io.xpipe.fxcomps.CompStructure; +import javafx.beans.binding.Bindings; +import javafx.scene.Node; +import javafx.scene.layout.Region; + +public class GrowAugment> implements Augment { + + private final boolean width; + private final boolean height; + private GrowAugment(boolean width, boolean height) { + this.width = width; + this.height = height; + } + + public static > GrowAugment create(boolean width, boolean height) { + return new GrowAugment<>(width, height); + } + + private void bind(Region r, Node parent) { + if (!(parent instanceof Region p)) { + return; + } + + if (width) { + r.prefWidthProperty() + .bind(Bindings.createDoubleBinding( + () -> p.getWidth() + - p.getInsets().getLeft() + - p.getInsets().getRight(), + p.widthProperty(), + p.insetsProperty())); + } + if (height) { + r.prefHeightProperty() + .bind(Bindings.createDoubleBinding( + () -> { + var val = p.getHeight() + - p.getInsets().getTop() + - p.getInsets().getBottom(); + if (val <= 0) { + return Region.USE_COMPUTED_SIZE; + } + return val; + }, + p.heightProperty(), + p.insetsProperty())); + } + } + + @Override + public void augment(S struc) { + struc.get().parentProperty().addListener((c, o, n) -> { + if (o instanceof Region) { + if (width) struc.get().prefWidthProperty().unbind(); + if (height) struc.get().prefHeightProperty().unbind(); + } + + bind(struc.get(), n); + }); + + bind(struc.get(), struc.get().getParent()); + } +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/augment/PopupMenuAugment.java b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/PopupMenuAugment.java new file mode 100644 index 00000000..5a8b9cdf --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/augment/PopupMenuAugment.java @@ -0,0 +1,31 @@ +package io.xpipe.fxcomps.augment; + +import io.xpipe.fxcomps.CompStructure; +import javafx.scene.control.ContextMenu; +import javafx.scene.input.MouseButton; + +public abstract class PopupMenuAugment> implements Augment { + + private final boolean showOnPrimaryButton; + + protected PopupMenuAugment(boolean showOnPrimaryButton) { + this.showOnPrimaryButton = showOnPrimaryButton; + } + + protected abstract ContextMenu createContextMenu(); + + @Override + public void augment(S struc) { + var cm = createContextMenu(); + var r = struc.get(); + r.setOnMousePressed(event -> { + if ((showOnPrimaryButton && event.getButton() == MouseButton.PRIMARY) + || (!showOnPrimaryButton && event.getButton() == MouseButton.SECONDARY)) { + cm.show(r, event.getScreenX(), event.getScreenY()); + event.consume(); + } else { + cm.hide(); + } + }); + } +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/comp/HorizontalComp.java b/fxcomps/src/main/java/io/xpipe/fxcomps/comp/HorizontalComp.java new file mode 100644 index 00000000..7241c32a --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/comp/HorizontalComp.java @@ -0,0 +1,29 @@ +package io.xpipe.fxcomps.comp; + +import io.xpipe.fxcomps.Comp; +import io.xpipe.fxcomps.CompStructure; +import io.xpipe.fxcomps.SimpleCompStructure; +import javafx.geometry.Pos; +import javafx.scene.layout.HBox; + +import java.util.List; + +public class HorizontalComp extends Comp> { + + private final List> entries; + + public HorizontalComp(List> comps) { + entries = List.copyOf(comps); + } + + @Override + public CompStructure createBase() { + HBox b = new HBox(); + b.getStyleClass().add("horizontal-comp"); + for (var entry : entries) { + b.getChildren().add(entry.createRegion()); + } + b.setAlignment(Pos.CENTER); + return new SimpleCompStructure<>(b); + } +} diff --git a/fxcomps/src/main/java/io/xpipe/fxcomps/comp/LabelComp.java b/fxcomps/src/main/java/io/xpipe/fxcomps/comp/LabelComp.java new file mode 100644 index 00000000..30b465c7 --- /dev/null +++ b/fxcomps/src/main/java/io/xpipe/fxcomps/comp/LabelComp.java @@ -0,0 +1,31 @@ +package io.xpipe.fxcomps.comp; + +import io.xpipe.fxcomps.Comp; +import io.xpipe.fxcomps.CompStructure; +import io.xpipe.fxcomps.SimpleCompStructure; +import io.xpipe.fxcomps.util.PlatformThread; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.geometry.Pos; +import javafx.scene.control.Label; + +public class LabelComp extends Comp> { + + private final ObservableValue text; + + public LabelComp(String text) { + this.text = new SimpleStringProperty(text); + } + + public LabelComp(ObservableValue text) { + this.text = PlatformThread.sync(text); + } + + @Override + public CompStructure