After the last post had some minor improvements for me and the beta release was actually used for some things, I decided that the GUI was too ugly to continue like this and rewrote the whole GUI in JavaFX.
The Matrjoshka - Usage
This posed some challenges for MVC / MVP, because of some peculiarities with JavaFX Views and Controllers. The final result of that rewrite is a matrjoshka-puppet-like structure, where the MVP-View of the Application is itself an MVC-based "application".
Unfortunately I have found no other way to properly abstract away the interactions with my Controllers and as of now my Main-Method looks like this:
public static void main(String[] args) {
if (args.length > 1) {
// don't even bother!
System.out.println(ARGUMENT_MISMATCH);
return;
}
launch(args);
}
@Override
public void start(Stage primaryStage) throws Exception {
Stage rcStage = new Stage(StageStyle.UTILITY);
rcStage.initOwner(primaryStage);
ResxChooser rc = new JFXResxChooserView(rcStage, getClass().getResource("/ResxChooser.fxml"));
Parameters params = getParameters();
if (params.getUnnamed().size() != 0) { // should be 1..
final Path resxFile = Paths.get(params.getUnnamed().get(0));
rc.setFileset(resxFile);
}
Stage translationStage = new Stage(StageStyle.UTILITY);
translationStage.initOwner(primaryStage);
TranslationView tv = new JFXTranslationView(translationStage, getClass().getResource("/TranslationView.fxml"));
OverviewView v = new JFXOverviewView(primaryStage, getClass().getResource("/OverviewView.fxml"));
OverviewModel m = new OverviewModel();
Dialog d = new JFXDialog();
OverviewPresenter p = new OverviewPresenter(m, v, tv, rc, d);
// Wire up all the crap
DependencyRoot.inject(m, v, p, tv, rc);
Platform.runLater(p::show);
Platform.runLater(p::fileChoosing);
}
As you can see the OverviewPresenter is basically the Octo-Kraken, having a reference to every significant instance. For now I'm not too worried about that, since all interactions are event-based and the refactoring to clean this is simplistic. It just hasn't been necessary yet.
What I'd much more like a review on is the way I set up my Views.
The Matrjoshka - Setup
As mentioned above this feels like a Matrjoshka-Puppet. The basic gist of it is, that JavaFX expects me to specify a fx:controller
in my fxml
-files that the UI elements are injected into using black magic @FXML
-Annnotations. Since that means I cannot control instantiation of that controller, let alone inject dependencies into it without resorting to a DI-Framework (which seems too heavy handed for my 5-odd dependencies) I wanted to abstract the FXML-Bound Controller into a class I can control.
The FXML is the following:
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ButtonBar?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.layout.BorderPane?>
<BorderPane xmlns:fx="http://javafx.com/fxml"
fx:controller="de.vogel612.helper.ui.jfx.JFXOverviewController"
minHeight="300" minWidth="600">
<padding>
<Insets top="15" bottom="20" right="15" left="15"/>
</padding>
<top>
<ButtonBar minHeight="30">
<buttons>
<Button fx:id="save" text="Save" minWidth="60" minHeight="20"/>
<Button fx:id="chooseLang" text="Choose Languages" minWidth="60" minHeight="20"/>
</buttons>
</ButtonBar>
</top>
<center>
<TableView fx:id="table"/>
</center>
</BorderPane>
Very simplistic. Just a table and two buttons. This gets bound to the JFXOverviewController that I cannot control:
public class JFXOverviewController extends OverviewViewCommon implements Initializable {
@FXML
private Button save;
@FXML
private Button chooseLang;
@FXML
private TableView<TranslationPair> table;
public JFXOverviewController() {
}
@Override
public void initialize() {
// empty?
}
@Override
public void show() {
throw new UnsupportedOperationException();
}
@Override
public void rebuildWith(List<Translation> left, List<Translation> right) {
if (left.isEmpty() || right.isEmpty()) {
return;
}
table.setItems(buildObservableList(left, right));
table.getColumns().get(0).setText(left.get(0).getLocale());
table.getColumns().get(1).setText(right.get(0).getLocale());
}
private static ObservableList<TranslationPair> buildObservableList(List<Translation> left,
List<Translation> right) {
List<TranslationPair> result = new ArrayList<>();
final int limit = Math.min(left.size(), right.size());
for (int i = 0; i < limit; i++) {
result.add(new TranslationPair(left.get(i), right.get(i)));
}
return FXCollections.observableList(result);
}
@Override
public void hide() {
throw new UnsupportedOperationException();
}
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
Objects.requireNonNull(save, "save was not FXML-injected correctly");
Objects.requireNonNull(table, "table was not FXML-injected correctly");
Objects.requireNonNull(chooseLang, "chooseLang was not FXML-injected correctly");
save.setOnAction(evt -> saveRequestListeners.forEach(Runnable::run));
chooseLang.setOnAction(evt -> langChoiceRequestListeners.forEach(Runnable::run));
Callback<TableColumn<TranslationPair,String>, TableCell<TranslationPair, String>> cellRenderer =
column -> {
TableCell<TranslationPair, String> cell = new TableCell<TranslationPair, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
setText(item);
TranslationPair rowValue = (TranslationPair) getTableRow().getItem();
if (rowValue != null) {
assignHighlightClasses(rowValue);
}
}
private void assignHighlightClasses(TranslationPair rowValue) {
getStyleClass().remove("default");
getStyleClass().remove("warn");
getStyleClass().remove("error");
switch (NotableData.assessNotability(rowValue)) {
case INFO:
case DEFAULT:
getStyleClass().add("default");
break;
case WARNING:
getStyleClass().add("warn");
break;
case ERROR:
getStyleClass().add("error");
break;
}
}
};
cell.addEventFilter(MouseEvent.MOUSE_CLICKED, evt -> {
// assume the double-click selected the relevant row....
if (evt.getButton() != MouseButton.PRIMARY || evt.getClickCount() != 2) {
return;
}
translationRequestListeners.forEach(listener -> {
listener.accept(table.getSelectionModel().getSelectedItem().getRight().getKey());
});
});
return cell;
};
TableColumn<TranslationPair, String> leftColumn = new TableColumn<>("");
leftColumn.setCellValueFactory(data -> new ObservableValueBase<String>() {
@Override
public String getValue() {
return data.getValue().getLeft().getValue();
}
});
leftColumn.setCellFactory(cellRenderer);
TableColumn<TranslationPair, String> rightColumn = new TableColumn<>("");
rightColumn.setCellValueFactory(data -> new ObservableValueBase<String>() {
@Override
public String getValue() {
return data.getValue().getRight().getValue();
}
});
rightColumn.setCellFactory(cellRenderer);
table.getColumns().clear();
table.getColumns().add(leftColumn);
table.getColumns().add(rightColumn);
table.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
table.setOnKeyPressed(event -> {
if (event.getCode() == KeyCode.ENTER && table.getSelectionModel().getSelectedItem() != null) {
translationRequestListeners.forEach(listener -> {
// so many assumptions :/
listener.accept(table.getSelectionModel().getSelectedItem().getRight().getKey());
});
}
});
table.setEditable(false);
}
// FIXME this is fugly
void triggerCloseRequest() {
windowCloseListeners.forEach(Runnable::run);
}
}
Since I'm not actually directly wiring any of this up and most of the logic in the View is directly related to the UI components, the Controller is very cramped. The Class actually instantiated is significantly less interesting in and of itself, because it mostly delegates to the Controller.
Interesting here is the Constructor:
public class JFXOverviewView implements OverviewView {
private final JFXOverviewController controller;
private final Scene ui;
private final Stage stage;
public JFXOverviewView(Stage stage, URL fxml) throws IOException {
this.stage = stage;
FXMLLoader loader = new FXMLLoader(fxml);
ui = new Scene(loader.load());
ui.getStylesheets().clear();
// this works fine
ui.getStylesheets().add(getClass().getResource("/style.css").toExternalForm());
controller = loader.getController();
stage.setOnCloseRequest(evt -> controller.triggerCloseRequest());
}
@Override
public void addWindowClosingListener(Runnable listener) {
controller.addWindowClosingListener(listener);
}
@Override
public void addLanguageRequestListener(Runnable listener) {
controller.addLanguageRequestListener(listener);
}
@Override
public void addTranslationRequestListener(Consumer<String> listener) {
controller.addTranslationRequestListener(listener);
}
@Override
public void addSaveRequestListener(Runnable listener) {
controller.addSaveRequestListener(listener);
}
@Override
public void initialize() {
// shouldn't be needed anymore
}
@Override
public void show() {
Platform.runLater(() -> {
stage.setScene(ui);
stage.show();
});
}
@Override
public void rebuildWith(List<Translation> left, List<Translation> right) {
controller.rebuildWith(left, right);
}
@Override
public void hide() {
stage.hide();
}
}
The Matrjoshka - Smelly or not smelly?
Basically my question boils down to: Does this smell? And if it does... how can I fix it?
As always comments on everything are appreciated.
The code is fully available on github