Fork me on GitHub

Maven Central Build Status Test Status Coverage Lines Grade Total alerts

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

UML

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();
  }

UML

Presentation

UML

DefaultMainPanelPresentation

UML

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

UML

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 UserActions. 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 UserActions 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 all String 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 the onSelected() 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 as UserActionProvider, 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:

  1. associated to an entity class already in the model;
  2. explicitly created in a presentation control, as shown above (this works for POJOs);
  3. create in a specific Presentable role, that acts as a presentation model factory.

UML

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 a Collection, eventually expanding arguments that are Collections 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 Displayables are used: instead directly calling the getter methods, they use Suppliers.

The _s() method is a simple utility method that wraps a method whose functional signature is similar to the one of a Supplier, but can throw a checked exception: it returns a Supplier 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

UML

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

UML

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 window
  • Splash.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:

There are also other quality analysis tools available:

API documentation

Aggregate Javadoc