Introduction
SteelBlue contains the JavaFX implementation of a set of UI-related DCI roles defined in the project TheseFoolishThings. It allows to design rich client applications in a fashion that is mostly independent of the UI technology so the code depending on it is segregated in a confined section.
SteelBlue benefits are:
- clean separation between UI technology and the rest of the application
- capability of fully testing the presentation model and related interactions without using test tools specific to the UI technology
- capability of reusing the same application core with multiple UI technologies
Table of contents
Quick introduction to DCI and As
TBD
Getting started
The application is partitioned in the following modules:
- Model: contains data entities and business controllers to elaborate them.
- PresentationModel: TBD
- Presentation: contains the description of the user's interaction and presentation controllers.
- JavaFX implementation: the only module containing objects from the specific UI technology (JavaFX).
Model
Let's introduce a bunch of model classes. First, a very simple one, a plain POJO with constructor and getters.
@AllArgsConstructor @Getter
public class SimpleEntity
{
@Nonnull
private final String name;
@Override @Nonnull
public String toString()
{
return String.format("SimpleEntity(%s)", name);
}
}
Then a slightly more complex entity, with three attributes and As
capability and a pre-provided Displayable
role.
@Getter @ToString(exclude = "asDelegate")
public class SimpleDciEntity implements As
{
@Delegate
private final As asDelegate;
@Nonnull
private final String name;
private final int attribute1;
private final int attribute2;
public SimpleDciEntity (@Nonnull final String id, final int attribute1, final int attribute2)
{
this.name = id;
this.attribute1 = attribute1;
this.attribute2 = attribute2;
asDelegate = As.forObject(this, Displayable.of(name));
}
}
Then a more complex entity, which in addition to attributes also acts as a dynamic composite. A composite object is one that contains sub-objects; it can be static if the capability is wired in the code, that is all instances of that class share the role. A dynamic one doesn't have the capability baked in the class, but implements it by composition; in this way, some instances can have it, others not. In this case, the condition for being a composite is to be a directory.
private FileEntity (@Nonnull final Path path)
{
this.path = path;
delegate = As.forObject(this, Files.isDirectory(path)
? List.of(SimpleComposite.of(new FileEntityFinder(path)))
: Collections.emptyList());
}
TODO: the whole listing of FileEntity is too much; either enumerate methods or just provide an UML diagram.
At last, a DAO provides collections of such objects.
public interface Dao
{
/** @return {a collection of simple entities} that are POJOs. */
@Nonnull
public Collection<SimpleEntity> getSimpleEntities();
/** @return {a collection of entities} supporting DCI roles. */
@Nonnull
public Collection<SimpleDciEntity> getDciEntities();
/** @return {a collection of file entities}. */
@Nonnull
public Collection<FileEntity> getFiles();
}
Presentation
DefaultMainPanelPresentation
This class is not implemented in the backend, but it will in specific implementations related to a UI techology (such as JavaFX). Nevertheless it can be mocked and used in tests.
DefaultMainPanelPresentationControl
First, the control must inject the related presentation and the DAOs:
@Nonnull
private final Dao dao;
@Nonnull
private final MainPanelPresentation presentation;
Simple interactions with the user are modelled by UserAction
s. They at least must be related to a callback method and a Displayable
that provides the label.
@Getter
private final UserAction actionButton = UserAction.of(this::onButtonPressed,
Displayable.of("Press me"));
@Getter
private final UserAction actionDialogOk = UserAction.of(this::onButtonDialogOkPressed,
Displayable.of("Dialog with ok"));
@Getter
private final UserAction actionDialogCancelOk = UserAction.of(this::onButtonDialogOkCancelPressed,
Displayable.of("Dialog with ok/cancel"));
@Getter
private final UserAction actionPickFile = UserAction.of(this::onButtonPickFilePressed,
Displayable.of("Pick file"));
@Getter
private final UserAction actionPickDirectory = UserAction.of(this::onButtonPickDirectoryPressed,
Displayable.of("Pick directory"));
After all the UserAction
s have been created, they must be packed together into a Bindings
object. This is usually an inner class of the presentation (it
is not a requirement; it can be a standard class as well); it
is convenient to use the builder pattern for improved code readability (in the code below the Builder
annotation from Lombok is used).
Note that it can contain instances of BoundProperties
: they are values that are rendered by the presentation, and can be just modified by the control;
thanks to a listener, the presentation will automatically update them. It is an alternate approach to call a notify()
method on the presentation.
@Builder
public static class Bindings
{
@Nonnull
public final UserAction actionButton;
@Nonnull
public final UserAction actionDialogOk;
@Nonnull
public final UserAction actionDialogCancelOk;
@Nonnull
public final UserAction actionPickFile;
@Nonnull
public final UserAction actionPickDirectory;
public final BoundProperty<String> textProperty = new BoundProperty<>("1");
public final BoundProperty<Boolean> booleanProperty = new BoundProperty<>(true);
}
private final Bindings bindings = Bindings.builder()
.actionButton(actionButton)
.actionDialogOk(actionDialogOk)
.actionDialogCancelOk(actionDialogCancelOk)
.actionPickFile(actionPickFile)
.actionPickDirectory(actionPickDirectory)
.build();
Simple actions requiring no further user interaction usually are a call to the presentation for displaying something. In addition, they can change a status
local to the control (status
) or modify a BoundProperty
.
While
BoundProperty
instances can be of any type, it's advisable they are allString
and formatted by the control. This favours reuse and facilitates testing.
private void onButtonPressed()
{
presentation.notify("Button pressed");
status++;
bindings.textProperty.set(Integer.toString(status));
}
When a confirmation is required from the user, a notificationWithFeedback()
object can be used. Mandatory attributes are a caption and a text, as well as a
callback that will be executed when the Ok button is pressed. The callback can be a request to display something in the presentation, or eventually start a
more complex computation.
private void onButtonDialogOkPressed()
{
presentation.notify(notificationWithFeedback()
.withCaption("Notification")
.withText("Now press the button")
.withFeedback(feedback().withOnConfirm(() -> presentation.notify("Pressed ok"))));
}
A variant is allowed when the user might cancel the action, providing an alternate callback.
private void onButtonDialogOkCancelPressed()
{
presentation.notify(notificationWithFeedback()
.withCaption("Notification")
.withText("Now press the button")
.withFeedback(feedback().withOnConfirm(() -> presentation.notify("Pressed ok"))
.withOnCancel(() -> presentation.notify("Pressed cancel"))));
}
TODO
private void onButtonPickFilePressed()
{
final var selectedFile = new BoundProperty<>(USER_HOME);
presentation.pickFile(selectedFile,
notificationWithFeedback()
.withCaption("Pick a file")
.withFeedback(feedback().withOnConfirm(() -> presentation.notify("Selected file: " + selectedFile.get()))
.withOnCancel(() -> presentation.notify("Selection cancelled"))));
}
TODO
private void onButtonPickDirectoryPressed()
{
final var selectedFolder = new BoundProperty<>(USER_HOME);
presentation.pickDirectory(selectedFolder,
notificationWithFeedback()
.withCaption("Pick a directory")
.withFeedback(feedback().withOnConfirm(() -> presentation.notify("Selected directory: " + selectedFolder.get()))
.withOnCancel(() -> presentation.notify("Selection cancelled"))));
}
When the control is initialized, it must call the presentation to pass the bindings, so the presentation implementation can relate them to the actual UI controls.
@PostConstruct
@VisibleForTesting void initialize()
{
presentation.bind(bindings);
}
There must be at least one method to activate the presentation, typically populating it with data. Here we see that entities1
, entities2
an files
are
first retrieved from the DAOs; then presentation models are created and collected into composite presentation models pm1
, pm2
and pm3
. At last,
the presentation models are passed to the presentation.
@Override
public void populate ()
{
final var entities1 = dao.getSimpleEntities();
final var entities2 = dao.getDciEntities();
final var files = dao.getFiles();
final var pm1 = entities1.stream().map(this::pmFor).collect(toCompositePresentationModel());
final var pm2 = entities2.stream().map(this::pmFor).collect(toCompositePresentationModel());
final var pm3 = files.stream()
.map(item -> item.as(_Presentable_).createPresentationModel())
.collect(toCompositePresentationModel(r(Visible.INVISIBLE)));
presentation.populate(pm1, pm2, pm3);
}
Each data entity that need to interact with a presentation need to have a presentation model. In the example:
- the entity can be selected in a list: a
Selectable
role is provided, which calls back theonSelected()
method. - three context actions are created (they will be presented in a contextual menu).
- at last, the presentation model will be rendered with the entity name.
The entity can be:
- a simple POJO, such as in this case;
- an object with associated DCI roles (i.e. it implements the
As
interface): in this case, all the roles such asUserActionProvider
,Selectable
etc… will be automatically part of the presentation model.
@Nonnull
private PresentationModel pmFor (@Nonnull final SimpleEntity entity)
{
final Selectable selectable = () -> onSelected(entity);
final var action1 = UserAction.of(() -> action1(entity), Displayable.of("Action 1"));
final var action2 = UserAction.of(() -> action2(entity), Displayable.of("Action 2"));
final var action3 = UserAction.of(() -> action3(entity), Displayable.of("Action 3"));
return PresentationModel.of(entity, r(Displayable.of("Item #" + entity.getName()),
selectable,
UserActionProvider.of(action1, action2, action3)));
}
Callbacks need one parameter, which is the object the action must be performed on.
private void onSelected (@Nonnull final Object object)
{
presentation.notify("Selected " + object);
}
private void action1 (@Nonnull final Object object)
{
presentation.notify("Action 1 on " + object);
}
In this second example, an Aggregate
role is also provided.
@Nonnull
private PresentationModel pmFor (@Nonnull final SimpleDciEntity entity)
{
// FIXME: column names
final Aggregate<PresentationModel> aggregate = PresentationModelAggregate.newInstance()
.withPmOf("C1", r(Displayable.of(entity.getName())))
.withPmOf("C2", r(Displayable.of("" + entity.getAttribute1())))
.withPmOf("C3", r(Displayable.of("" + entity.getAttribute2())));
final Selectable selectable = () -> onSelected(entity);
final var action1 = UserAction.of(() -> action1(entity), Displayable.of("Action 1"));
final var action2 = UserAction.of(() -> action2(entity), Displayable.of("Action 2"));
final var action3 = UserAction.of(() -> action3(entity), Displayable.of("Action 3"));
// No explicit Displayable here, as the one inside SimpleDciEntity is used.
return PresentationModel.of(entity, r(aggregate, selectable, UserActionProvider.of(action1, action2, action3)));
}
Presentation models can be created in various way:
- associated to an entity class already in the model;
- explicitly created in a presentation control, as shown above (this works for POJOs);
- create in a specific
Presentable
role, that acts as a presentation model factory.
The Presentable
approach is the recommended one (unless the entity is very simple) since in practice the runtime will ask each entity to create is
presentation model. In particular this is important for composite entities, unless one wants to write code that recursively navigate the tree of child
entities; a task that would be cumbersome to implement in case of heterogeneous composites.
For the entity FileEntity
the Presentable
approach has been provided. It uses an Aggregate
role, required when the entity is going to be presented
in a UI control with columns (such as a table or a tree-table). The aggregate provides multiple presentation model instances, one for each column.
The
r()
method is a simple utility method that accepts varargs arguments and pack them into aCollection
, eventually expanding arguments that areCollection
s themselves.
@DciRole(datumType = FileEntity.class) @Slf4j
public class FileEntityPresentable extends SimpleCompositePresentable
{
private static final DateTimeFormatter FORMATTER = getDateTimeFormatterFor(FormatStyle.SHORT, Locale.getDefault());
@Nonnull
private final FileEntity owner;
public FileEntityPresentable (@Nonnull final FileEntity owner)
{
super(owner);
this.owner = owner;
}
@Override @Nonnull
public PresentationModel createPresentationModel (@Nonnull final Collection<Object> roles)
{
final var aggregate = PresentationModelAggregate.newInstance()
.withPmOf("name",
r(owner)) // owner is a Displayable itself
.withPmOf("size",
r(Displayable.of(_s(() -> "" + owner.getSize())),
Styleable.of("right-aligned")))
.withPmOf("creationDate",
r(Displayable.of(_s(() -> FORMATTER.format(owner.getCreationDateTime()))),
Styleable.of("right-aligned")))
.withPmOf("latestModificationDate",
r(Displayable.of(_s(() -> FORMATTER.format(owner.getLastModifiedDateTime()))),
Styleable.of("right-aligned")));
return super.createPresentationModel(r(aggregate, roles));
}
}
Note that since the owner object is mutable (it represents a file, that can change attributes during its life cycle), deferred Displayable
s are used:
instead directly calling the getter methods, they use Supplier
s.
The
_s()
method is a simple utility method that wraps a method whose functional signature is similar to the one of aSupplier
, but can throw a checked exception: it returns aSupplier
with no checked exceptions.
Testing
Given that this module does not depend on UI technology, testing is easy and can be performed with the usual tools. Below some sketches will be illustrated that make use of TestNG and Mockito.
First, mocks are created; they are one or more DAOs (or similar classes) and one presentation.
private Dao dao;
private MainPanelPresentation presentation;
private DefaultMainPanelPresentationControl underTest;
private MainPanelPresentation.Bindings bindings;
@BeforeMethod
public void setup()
{
dao = mock(Dao.class);
presentation = mock(MainPanelPresentation.class);
// A capturer matcher intercepts the method call and stores the argument for later retrieval, by calling getCapture().
final var captureBindings = new CapturerMatcher<MainPanelPresentation.Bindings>();
underTest = new DefaultMainPanelPresentationControl(dao, presentation);
underTest.initialize();
verify(presentation).bind(argThat(captureBindings));
bindings = captureBindings.getCaptured();
}
Simple button interactions can be tested in a straightforward fashion.
@Test
public void test_buttonAction()
{
// given
underTest.status = 1;
// when
bindings.actionButton.actionPerformed();
// then
verifyNoInteractions(dao);
verify(presentation).notify("Button pressed");
// TBD: test bound property incremented
verifyNoMoreInteractions(presentation);
assertThat(bindings.textProperty.get(), is("2"));
}
Confirmation dialogs can be tested with a few lines of code, also thanks to helper methods such as confirm()
.
@Test
public void test_actionDialogOk_confirm()
{
// given
doAnswer(confirm()).when(presentation).notify(any(UserNotificationWithFeedback.class));
// when
bindings.actionDialogOk.actionPerformed();
// then
verifyNoInteractions(dao);
verify(presentation).notify(argThat(notificationWithFeedback("Notification", "Now press the button")));
verify(presentation).notify("Pressed ok");
verifyNoMoreInteractions(presentation);
}
Also dialog cancellations can be tested in the same way.
@Test
public void test_actionDialogCancelOk_cancel()
{
// given
doAnswer(cancel()).when(presentation).notify(any(UserNotificationWithFeedback.class));
// when
bindings.actionDialogCancelOk.actionPerformed();
// then
verifyNoInteractions(dao);
verify(presentation).notify(argThat(notificationWithFeedback("Notification", "Now press the button")));
verify(presentation).notify("Pressed cancel");
verifyNoMoreInteractions(presentation);
}
More complex interactions such as those with the file picker require a bit more code, but still under the dozen of lines.
@Test
public void test_actionPickFile_confirm()
{
// given
final var home = Path.of(System.getProperty("user.home"));
//final var picked = Path.of("file.txt");
// final Consumer<InvocationOnMock> responseSetter = i -> i.getArgument(0, BoundProperty.class).set(picked);
// doAnswer(doAndConfirm(responseSetter)).when(presentation).pickFile(any(BoundProperty.class), any(UserNotificationWithFeedback.class));
doAnswer(confirm()).when(presentation).pickFile(any(BoundProperty.class), any(UserNotificationWithFeedback.class));
// when
bindings.actionPickFile.actionPerformed();
// then
verifyNoInteractions(dao);
verify(presentation).pickFile(eq(new BoundProperty<>(home)), argThat(notificationWithFeedback("Pick a file", "")));
verify(presentation).notify("Selected file: " + home);
verifyNoMoreInteractions(presentation);
}
At last, an example of testing an interaction involving DAOs and composite entities. In this case the test is clearly more complex, but it's due to the fact that a lot of things are tested, included interactions.
@Test
public void test_populate ()
{
// given
final var simpleEntity = new SimpleEntity("simple entity");
final var simpleDciEntity = new SimpleDciEntity("id", 3, 4);
final var fe = FileEntity.of(Path.of("src/test/resources/test-file.txt"));
final var fes = spy(fe);
doReturn(new FileEntityPresentable(fe)).when(fes).as(_Presentable_);
// TODO: should create lists with more than 1 element...
when(dao.getSimpleEntities()).thenReturn(List.of(simpleEntity));
when(dao.getDciEntities()).thenReturn(List.of(simpleDciEntity));
when(dao.getFiles()).thenReturn(List.of(fes));
// when
underTest.populate();
// then
verify(dao).getSimpleEntities();
verify(dao).getDciEntities();
verify(dao).getFiles();
verifyNoMoreInteractions(dao);
final var cpm1 = new CapturerMatcher<PresentationModel>();
final var cpm2 = new CapturerMatcher<PresentationModel>();
final var cpm3 = new CapturerMatcher<PresentationModel>();
verify(presentation).populate(argThat(cpm1), argThat(cpm2), argThat(cpm3));
final var pmList1 = toPmList(cpm1.getCaptured());
assertThat(pmList1.size(), is(1));
pmList1.forEach(pm -> assertPresentationModel(pm, "Item #simple entity", "SimpleEntity(simple entity)"));
final var pmList2 = toPmList(cpm2.getCaptured());
assertThat(pmList2.size(), is(1));
pmList2.forEach(pm ->
{
assertPresentationModel(pm, simpleDciEntity.getName(), simpleDciEntity.toString());
final Aggregate<PresentationModel> aggregate = pm.as(_Aggregate_);
assertThat(aggregate.getNames(), is(Set.of("C1", "C2", "C3")));
final var pmc1 = aggregate.getByName("C1").orElseThrow();
final var pmc2 = aggregate.getByName("C2").orElseThrow();
final var pmc3 = aggregate.getByName("C3").orElseThrow();
assertThat(pmc1.as(_Displayable_).getDisplayName(), is(simpleDciEntity.getName()));
assertThat(pmc2.as(_Displayable_).getDisplayName(), is("" + simpleDciEntity.getAttribute1()));
assertThat(pmc3.as(_Displayable_).getDisplayName(), is("" + simpleDciEntity.getAttribute2()));
});
final var pmList3 = toPmList(cpm3.getCaptured());
assertThat(pmList3.size(), is(1));
pmList3.forEach(_c(pm ->
{
final var displayable = pm.as(_Displayable_);
assertThat(displayable.getDisplayName(), is(fe.getDisplayName()));
final Aggregate<PresentationModel> aggregate = pm.as(_Aggregate_);
assertThat(aggregate.getNames(), is(Set.of("name", "size", "creationDate", "latestModificationDate")));
final var pmc1 = aggregate.getByName("name").orElseThrow();
final var pmc2 = aggregate.getByName("size").orElseThrow();
final var pmc3 = aggregate.getByName("creationDate").orElseThrow();
final var pmc4 = aggregate.getByName("latestModificationDate").orElseThrow();
assertThat(pmc1.as(_Displayable_).getDisplayName(), is(fe.getDisplayName()));
assertThat(pmc2.as(_Displayable_).getDisplayName(), is("" + fe.getSize()));
// Commented out because they keep changing, but you get the point.
// assertThat(pmc3.as(_Displayable_).getDisplayName(), is("12/18/24 11:25 am"));
// assertThat(pmc4.as(_Displayable_).getDisplayName(), is("12/18/24 11:28 am"));
}));
// FIXME verifyNoMoreInteractions(presentation);
}
private void assertPresentationModel (@Nonnull final PresentationModel pm,
@Nonnull final String expectedDisplayName,
@Nonnull final String expectedToString)
{
final var displayable = pm.as(_Displayable_);
final var selectable = pm.as(_Selectable_);
final var userActionProvider = pm.as(_UserActionProvider_);
assertThat(displayable.getDisplayName(), is(expectedDisplayName));
selectable.select();
verify(presentation).notify("Selected " + expectedToString);
final var actions = new ArrayList<>(userActionProvider.getActions());
assertThat(actions.size(), is(3));
actions.get(0).actionPerformed();
verify(presentation).notify("Action 1 on " + expectedToString);
actions.get(1).actionPerformed();
verify(presentation).notify("Action 2 on " + expectedToString);
actions.get(2).actionPerformed();
verify(presentation).notify("Action 3 on " + expectedToString);
}
@Nonnull
private static List<PresentationModel> toPmList (@Nonnull final PresentationModel pm)
{
final SimpleComposite<PresentationModel> sc = pm.as(_SimpleComposite_);
return sc.stream().collect(Collectors.toList());
}
JavaFX implementation
The JavaFX implementation of the application must provide:
- implementations of presentation interfaces (presentation controls doesn't need any further code);
- JavaFX layout resources (
.fxml
files); - a
main()
that starts the application.
Actually there are two implementations for each presentation interface:
- the actual implementation, called “presentation delegate”
- a decorator implementation that takes care of threading issues.
The reason for this duplication is that the former class is instantiated by the JavaFX runtime (the class is declared in a .fxml
resource),
while the latter needs to be instantiated by Spring.
TODO: mention that threading is automatic. Add in the control description that the reason for which notify methods are always called is bacause of threading.
JavaFXMainPanelPresentationDelegate
The presentation delegate is the class referred by the .fxml
resource associated to the presentation (in this case MainPanel.fxml
).
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.control.TreeTableColumn?>
<?import javafx.scene.control.TreeTableView?>
<?import javafx.scene.control.TreeView?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<BorderPane styleClass="mainFxmlClass" xmlns="http://javafx.com/javafx/23.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="it.tidalwave.role.ui.example.presentation.impl.javafx.JavaFXMainPanelPresentationDelegate">
<bottom>
<TextArea fx:id="taLog" BorderPane.alignment="CENTER" />
</bottom>
<center>
<BorderPane BorderPane.alignment="CENTER">
<left>
<HBox maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" spacing="8.0" BorderPane.alignment="CENTER">
<VBox maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" spacing="16.0">
<ComboBox fx:id="cbComboBox" />
<ListView fx:id="lvListView" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" />
</VBox>
<TableView fx:id="tvTableView" maxWidth="1.7976931348623157E308">
<columns>
<TableColumn prefWidth="100.0" text="C1" />
<TableColumn prefWidth="80.0" text="C2" />
<TableColumn prefWidth="80.0" text="C3" />
</columns>
</TableView>
<TreeView fx:id="tvTreeView" maxWidth="1.7976931348623157E308" minWidth="300" />
<BorderPane.margin>
<Insets right="8.0" />
</BorderPane.margin>
</HBox>
</left>
<center>
<TreeTableView fx:id="ttvTreeTableView" maxWidth="1.7976931348623157E308" BorderPane.alignment="CENTER">
<columns>
<TreeTableColumn prefWidth="300.0" text="name" />
<TreeTableColumn prefWidth="100.0" text="size" />
<TreeTableColumn prefWidth="150.0" text="creationDate" />
<TreeTableColumn prefWidth="150.0" text="latestModificationDate" />
</columns>
</TreeTableView>
</center>
<opaqueInsets>
<Insets />
</opaqueInsets>
<BorderPane.margin>
<Insets bottom="16.0" top="16.0" />
</BorderPane.margin>
</BorderPane>
</center>
<padding>
<Insets bottom="16.0" left="16.0" right="16.0" top="16.0" />
</padding>
<top>
<VBox maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" spacing="16.0" BorderPane.alignment="CENTER">
<HBox alignment="BASELINE_LEFT" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" spacing="8.0" VBox.vgrow="ALWAYS">
<Button fx:id="btButton" mnemonicParsing="false" prefHeight="24.0" text="Button" />
<TextField fx:id="tfTextField" prefHeight="24.0" />
<CheckBox mnemonicParsing="false" text="CheckBox" />
</HBox>
<HBox spacing="8.0">
<Button fx:id="btDialogOk" mnemonicParsing="false" text="Button" />
<Button fx:id="btDialogOkCancel" mnemonicParsing="false" text="Button" />
<Button fx:id="btPickFile" mnemonicParsing="false" text="Button" />
<Button fx:id="btPickDirectory" mnemonicParsing="false" text="Button" />
</HBox>
</VBox>
</top>
</BorderPane>
This class must first inject the required fields: an instance of the JavaFXBinder
utility and the references to all the JavaFX widgets in the interface.
// This facility provides all the methods to bind JavaFX controls to DCI roles.
@Nonnull
private final JavaFXBinder binder;
@FXML
private Button btButton;
@FXML
private Button btDialogOk;
@FXML
private Button btDialogOkCancel;
@FXML
private Button btPickFile;
@FXML
private Button btPickDirectory;
@FXML
private TextField tfTextField;
@FXML
private ListView<PresentationModel> lvListView;
@FXML
private ComboBox<PresentationModel> cbComboBox;
@FXML
private TableView<PresentationModel> tvTableView;
@FXML
private TreeView<PresentationModel> tvTreeView;
@FXML
private TreeTableView<PresentationModel> ttvTreeTableView;
@FXML
private TextArea taLog;
Unfortunately it's not possible to inject fields by constructor, since this feature is not supported by the JavaFX runtime.
A bind()
method, called just once, must use the binder
to create associations between UserAction
and BoundProperty
instances and the JavaFX widgets. Bindings are
by default unidirectional, that is e.g. a button calls a UserAction
; in some cases, they can be bidirectional, as in the tfTextField
example, because
changes can be originated both by the code and from the user's interaction.
@Override
public void bind (@Nonnull final Bindings bindings)
{
binder.bind(btButton, bindings.actionButton);
binder.bind(btDialogOk, bindings.actionDialogOk);
binder.bind(btDialogOkCancel, bindings.actionDialogCancelOk);
binder.bind(btPickFile, bindings.actionPickFile);
binder.bind(btPickDirectory, bindings.actionPickDirectory);
binder.bindBidirectionally(tfTextField, bindings.textProperty, bindings.booleanProperty);
}
When the control requires to update the rendering of data, it calls a method passing the related presentation models. Again the binder
makes it possible
to re-associate the rendering widgets with the new data.
bind()
accepts an optional callback, that will be called when the binding operation has been completed. This feature is not trivial, because manipulation of JavaFX widgets always happen in the JavaFX thread, while the callback is executed in a regular thread. In this case, callbacks are used for logging purposes.
@Override
public void populate (@Nonnull final PresentationModel pmSimpleEntities,
@Nonnull final PresentationModel pmDciEntities,
@Nonnull final PresentationModel pmFileEntities)
{
binder.bind(lvListView, pmSimpleEntities, () -> log.info("Finished setup of lvListView"));
binder.bind(cbComboBox, pmSimpleEntities, () -> log.info("Finished setup of cbComboBox"));
binder.bind(tvTableView, pmDciEntities, () -> log.info("Finished setup of tvTableView"));
binder.bind(tvTreeView, pmFileEntities, () -> log.info("Finished setup of tvTreeView"));
binder.bind(ttvTreeTableView, pmFileEntities, () -> log.info("Finished setup of ttvTreeTableView"));
}
Some notification methods are very simple, as they just call methods to manipulate the JavaFX widgets.
@Override
public void notify (@Nonnull final String message)
{
taLog.appendText(message + "\n");
}
Notifications and special dialogs are also executed by binder
.
@Override
public void notify (@Nonnull final UserNotificationWithFeedback notification)
{
binder.showInModalDialog(notification);
}
@Override
public void pickFile (@Nonnull final BoundProperty<Path> selectedFile,
@Nonnull final UserNotificationWithFeedback notification)
{
binder.openFileChooserFor(notification, selectedFile);
}
@Override
public void pickDirectory (@Nonnull final BoundProperty<Path> selectedFolder,
@Nonnull final UserNotificationWithFeedback notification)
{
binder.openDirectoryChooserFor(notification, selectedFolder);
}
JavaFXMainPanelPresentation
The decorator implementation is always created in the same way, as in the following code example. The returned delegate is indeed a dynamic proxy that wraps
all method calls in JavaFX Platform.runLater()
, thus the programmer need not to take care of threading issues.
The use of Lombok @Delegate
dramatically simplifies the implementation of this class.
public class JavaFXMainPanelPresentation implements MainPanelPresentation
{
@Getter
private final NodeAndDelegate<? extends MainPanelPresentation> nad = createNodeAndDelegate(getClass());
@Delegate
private final MainPanelPresentation delegate = nad.getDelegate();
}
The .fxml
resource by default has the same name of the presentation class, with the words JavaFX
and Presentation
removed, and must be in the same
package.
An overloaded version of the method
createNodeAndDelegate()
allows a second parameter with the full path of the.fxml
resource
JavaFXApplicationPresentationDelegate
????
TODO UML diagram
Main class
The main
of the application must perform the following tasks:
- set the
PROP_APP_NAME
with the name of the application; - configure the JavaFX
Platform
for explicit exit; - sets some default properties.
Optional properties:
KEY_INITIAL_SIZE
: the initial size of the window, as a percentage of the screen size.KEY_FULL_SCREEN
:true
if the application must start at full screen.PROP_SUPPRESS_CONSOLE
: TBD.
public static void main (@Nonnull final String ... args)
{
try
{
System.setProperty(PreferencesHandler.PROP_APP_NAME, "SteelBlueExample");
Platform.setImplicitExit(true);
final var preferencesHandler = PreferencesHandler.getInstance();
preferencesHandler.setDefaultProperty(KEY_INITIAL_SIZE, 0.8);
System.setProperty("it.tidalwave.role.ui.example.logFolder", preferencesHandler.getLogFolder().toAbsolutePath().toString());
launch(args);
}
catch (Throwable t)
{
// Don't use logging facilities here, they could be not initialized
t.printStackTrace();
System.exit(-1);
}
}
The method onStageCreated()
must be used to initialize the control beans.
There's no other valid way to do that;
@PostConstruct
annotated methods won't work because of threading issues.
@Override
protected void onStageCreated (@Nonnull final ApplicationContext applicationContext)
{
applicationContext.getBean(MainPanelPresentationControl.class).populate();
}
Two fxml
resources must be provided in the same package of the Main
class:
Application.fxml
describes the layout of the main windowSplash.fxml
describes a splash pop up that will be rendered during the initialisation of the application
It's common to have multiple .FXML
files, for instance one describing the general layout of the application (e.g. a toolbar at the top, a center
content pane and a bottom status bar) and one specific for each panel in the application. In this case they must be programmatically assembled together.
The proper way to do that is to have the main presentation delegate to implement PresentationAssembler
: the assemble()
method will be called back with
the initialized ApplicationContext
, so the code can retrieve any other presentation and their related Node
.
public class JavaFXApplicationPresentationDelegate implements PresentationAssembler
{
@Inject
private JavaFXBinder binder;
@Inject
private ToolBarModel toolBarModel;
@FXML
private BorderPane pnMainPane;
@FXML
private ToolBar tbToolBar;
@Override
public void assemble (@Nonnull final ApplicationContext applicationContext)
{
toolBarModel.populate(binder, tbToolBar);
final var mainPanelPresentation = applicationContext.getBean(JavaFXMainPanelPresentation.class);
pnMainPane.setCenter(mainPanelPresentation.getNad().getNode());
}
}
Assembling all the code
Since Spring is behind the scene, all the classes must be instantiated with a standard Spring approach. The preferred way nowadays is by using annotations; in
this case the main class must extend JavaFXSpringAnnotationApplication
and have the following annotations:
@Configuration
@EnableSpringConfigured
@EnableAspectJAutoProxy
@ComponentScan(basePackages = "it.tidalwave")
public class Main extends JavaFXSpringAnnotationApplication
The beans participating in the example need to be instantiated. In the example code, they are auto-scanned thanks to the Spring @Component
annotation.
It is also possible — while not recommended — to use the old Spring .xml
configuration approach: in this case the base class to extend is JavaFXSpringApplication
.
The classpath is scanned for all the Spring beans files placed under the META-INF
directory and whose name ends with AutoBeans.xml
. Furthermore,
specific operating system configuration files are searched for:
- for Linux, files whose name ends with
AutoLinuxBeans.xml
- for macOS, files whose name ends with
AutoMacOSXBeans.xml
- for Windows, files whose name ends with
AutoWindowsBeans.xml
TODO: required dependencies, profiles, aspects… etc.
Modules
General information
Maven dependency
TBD
Sources, issue tracker and continuous integration
The primary source repository is on Bitbucket, a secondary repository (synchronized in real time) is available on GitHub.
To checkout sources from Bitbucket:
> git clone https://bitbucket.org/tidalwave/steelblue-src
To checkout sources from GitHub:
> git clone https://github.com/tidalwave-it/steelblue-src
The issue tracker is hosted on the Atlassian Jira Cloud:
The continuous integration is available at:
- Tidalwave CI server (primary): http://services.tidalwave.it/ci/view/SteelBlue
- Travis: https://travis-ci.org/github/tidalwave-it/solidblue-src
- Bitbucket pipelines (demonstration only): https://bitbucket.org/tidalwave/solidblue-src/addon/pipelines/home
There are also other quality analysis tools available: