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))) : 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
MainPanelPresentation
This interface is not implemented in the backend, but it will in specific implementations related to a UI technology (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, List.of(
Displayable.of("Dialog with ok/cancel"),
MenuPlacement.under("Tools")));
@Getter
private final UserAction actionPickFile = UserAction.of(this::onButtonPickFilePressed, List.of(
Displayable.of("Open file..."),
MenuPlacement.under("File")));
@Getter
private final UserAction actionPickDirectory = UserAction.of(this::onButtonPickDirectoryPressed, List.of(
Displayable.of("Open directory..."),
MenuPlacement.under("File")));
As it can be seen in the code, a few actions are also related to a
MenuPlacement
role: this has to do with the creation of the application menu, that will be described below. Anyway, in short, they are declaring under which main menu element they should be placed (File
orTools
).
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"))));
}
User interaction that possibly return a value can use instances of BoundProperty
. In the code below, selectedFile
is used in a bidirectional fashion: both
for setting the starting point from which browsing the filesystem and for retrieving the selection.
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"))));
}
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 are limitations to the use of
@PostConstruct
, that will be explained below.
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 pmEnt1 = entities1.stream().map(this::pmFor).collect(toCompositePresentationModel());
final var pmEnt2 = entities2.stream().map(this::pmFor).collect(toCompositePresentationModel());
final var pmFiles = files.stream()
.map(item -> item.as(_Presentable_).createPresentationModel())
.collect(toCompositePresentationModel(r(Visibility.INVISIBLE)));
presentation.populate(pmEnt1, pmEnt2, pmFiles);
}
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)));
}
FileEntityPresentable
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. 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.
@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.
Action providers
Sometimes actions do not strictly live inside a presentation/control, but must be bound to global facilities such as toolbars and menu bars. On this purpose,
one or more UserActionProvider
roles can be instantiated. They can be associated to ToolBarControl
or MenuBarControl
or both (as in the example below).
@RequiredArgsConstructor @DciRole(datumType = {ToolBarControl.class, MenuBarControl.class})
public class MainPanelUserActionProvider extends DefaultUserActionProvider
{
@Nonnull
private final DefaultMainPanelPresentationControl pc;
@Override @Nonnull
public Collection<? extends UserAction> getActions()
{
return List.of(pc.getActionButton(), pc.getActionDialogOk(), pc.getActionDialogCancelOk(), pc.getActionPickFile(), pc.getActionPickDirectory());
}
}
The purpose of this class is to return a list of UserAction
s that can be instantiated locally or retrieved elsewhere, if they are already available (as in
this case). Note that all actions will be made available to a toolbar, while only those associated with a MenuPlacement
will be attached to a menu bar.
It is possible to put some logics in the code to decide which actions should be exposed; if the rationale for the toolbar is different than the one for the
menu bar, two separate UserActionProvider
s must be provided.
Multiple UserActionProvider
s related to the same target (toolbar or menu bar) can be present, and this is indeed a good design approach as each one should
be related to a different module in the system.
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 @SuppressWarnings("unchecked")
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(_PresentationModelAggregate_);
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 var aggregate = pm.as(_PresentationModelAggregate_);
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<? extends PresentationModel> toPmList (@Nonnull final PresentationModel pm)
{
return pm.as(_PresentationModelComposite_).stream().toList();
}
_c()
is a utility static method that wraps aConsumer
that can throw a checked exception into aConsumer
that wraps it into aRuntimeException
, thus being compatible with theStream
API without requiring ugly lookingtry / catch
blocks.
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.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
TBD
public class JavaFXApplicationPresentationDelegate implements PresentationAssembler
{
@Inject
private JavaFXBinder binder;
@Inject
private JavaFXToolBarControl toolBarModel;
@Inject
private JavaFXMenuBarControl menuBarModel;
@FXML
private BorderPane pnMainPane;
@FXML
private ToolBar tbToolBar;
@FXML
private MenuBar mbMenuBar;
@Override
public void assemble (@Nonnull final ApplicationContext applicationContext)
{
toolBarModel.populate(binder, tbToolBar);
menuBarModel.populate(binder, mbMenuBar);
final var mainPanelPresentation = applicationContext.getBean(JavaFXMainPanelPresentation.class);
pnMainPane.setCenter(mainPanelPresentation.getNad().getNode());
}
}
Main class
The main
of the application must call a launch method specifying a bunch of properties, as shown in the following code snippet:
public static void main (@Nonnull final String [] args)
{
launch(Main.class,
params().withArgs(args)
.withApplicationName("SteelBlueExample")
.withLogFolderPropertyName("it.tidalwave.ui.example.logFolder") // referenced in the logger configuration
// .withProperty(K_FULL_SCREEN, true)
// .withProperty(K_FULL_SCREEN_LOCKED, true)
// .withProperty(K_MAXIMIZED, true)
.withProperty(K_MIN_SPLASH_DURATION, Duration.seconds(3))
.withProperty(K_INITIAL_SIZE, 0.8)); // initial windows size (in percentage)
}
These properties are mandatory:
- the application name;
- the name of a system property that will be set with the path of the log folder and referenced in the logger configuration file.
Optional properties:
K_INITIAL_SIZE
: the initial size of the window, as a percentage of the screen size.K_FULL_SCREEN
:true
if the application must start at full screen.K_FULL_SCREEN_LOCKED
:true
if the application must stay all the time at full screen.K_MAXIMIZED
:true
if the application must start maximized.K_MIN_SPLASH_DURATION
: the minimum duration of the splash screen.
The method onStageCreated()
can be used to initialize the control beans.
@PostConstruct
annotated methods should be carefully used because of threading issues (see STB-78). In particular, theas()
subsystem might not properly be initialised when post-initialisers are called. This might cause an exception or even deadlock the application at start. Post-constructors are fine when they are just used by a control to talk to the presentation to set bindings.
@Override
protected void onStageCreated (@Nonnull final ApplicationContext applicationContext)
{
// Because of STB-78, it's advisable not to user @PostConstruct to initialise controllers.
applicationContext.getBean(MainPanelPresentationControl.class).populate();
// If one likes pubsub, an alternate approach is to fire an event to notify initialization.
// See also EnableMessageBus to simplify implementation.
// applicationContext.getBean(MessageBus.class).publish(new PowerOnEvent());
}
An alternate way is by using a pubsub facility, as explained in the next section.
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 JavaFXToolBarControl toolBarModel;
@Inject
private JavaFXMenuBarControl menuBarModel;
@FXML
private BorderPane pnMainPane;
@FXML
private ToolBar tbToolBar;
@FXML
private MenuBar mbMenuBar;
@Override
public void assemble (@Nonnull final ApplicationContext applicationContext)
{
toolBarModel.populate(binder, tbToolBar);
menuBarModel.populate(binder, mbMenuBar);
final var mainPanelPresentation = applicationContext.getBean(JavaFXMainPanelPresentation.class);
pnMainPane.setCenter(mainPanelPresentation.getNad().getNode());
}
}
Pubsub architecture
Instead of exposing public method of controllers, they can be interfaceless and react to events. If the main class is annotated with @EnableMessageBus
the
following things happen:
- a messagebus implementing
SimpleMessageBus
is instantiated and made available in the Spring application context (so it can be injected); - a
PowerOnEvent
is fired when the application is ready to be initialised; - a
PowerOffEvent
is fired when the application is going to be closed.
A controller can be notified about events by writing a simple method with an annotated parameter:
@VisibleForTesting void onPowerOn (@ListensTo final PowerOnEvent event)
{
presentation.bind(bindings);
populate();
}
The method can be private, but a package visibility is useful for testing.
WIth this approach, a valid implementation of the message bus must be added to the classpath:
<dependency>
<groupId>it.tidalwave.thesefoolishthings</groupId>
<artifactId>it-tidalwave-messagebus-spring</artifactId>
</dependency>
Also, AspectJ and Spring AOP are required to make the listener methods discovered and registered.
If the Tidalwave SuperPOM is used, all the required dependencies can be added by enabling the profile
it.tidalwave-spring-messagebus-v1
.
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")
// @EnableMessageBus
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 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: