Fork me on GitHub

Maven Central Build Status Test Status Coverage Lines Grade Total alerts

Introduction

SteelBlue contains a set of UI abstractions based on DCI roles and JavaFX bindings. It allows to create rich client applications in a way that is mostly independent of the UI technology; so testing is easie, and it is possible to create applications with multiple user interfaces.

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))) : 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

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

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, 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 or Tools).

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

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

FileEntityPresentable

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. 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.

@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.

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 UserActions 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 UserActionProviders must be provided. Multiple UserActionProviders 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 are illustrated, which 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 = ArgumentCaptor.forClass(MainPanelPresentation.Bindings.class);
        underTest = new DefaultMainPanelPresentationControl(dao, presentation);
        underTest.initialize();
        verify(presentation).bind(captureBindings.capture());
        bindings = captureBindings.getValue();
      }

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 = ArgumentCaptor.forClass(PresentationModel.class);
        final var cpm2 = ArgumentCaptor.forClass(PresentationModel.class);
        final var cpm3 = ArgumentCaptor.forClass(PresentationModel.class);
        verify(presentation).populate(cpm1.capture(), cpm2.capture(), cpm3.capture());

        final var pmList1 = toPmList(cpm1.getValue());
        assertThat(pmList1.size(), is(1));
        pmList1.forEach(pm -> assertPresentationModel(pm, "Item #simple entity", "SimpleEntity(simple entity)"));

        final var pmList2 = toPmList(cpm2.getValue());
        assertThat(pmList2.size(), is(1));
        pmList2.forEach(pm ->
          {
            assertPresentationModel(pm, simpleDciEntity.getName(), simpleDciEntity.toString());

            final var 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.getValue());
        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 a Consumer that can throw a checked exception into a Consumer that wraps it into a RuntimeException, thus being compatible with the Stream API without requiring ugly looking try / 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

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.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

TBD

UML

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, the as() 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 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 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:

There are also other quality analysis tools available:

API documentation

Aggregate Javadoc