Add SceneBuilder integration (#27)

This commit is contained in:
mkpaz 2023-02-16 21:03:30 +04:00
parent fbbcaa37f9
commit 5add5ce137
21 changed files with 1128 additions and 17 deletions

@ -4,9 +4,11 @@
### Features
- (Base) New `DeckPane` component with swipe and slide transition support.
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
- (CSS) 🚀 New `TabPane` style. There are three styles supported: default, floating and classic (new one).
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).
- (Sampler) 🚀 SceneBuilder integration. AtlantaFX themes can be installed (or uninstalled) to SceneBuilder directly from the Sampler app.
### Improvements

@ -6,6 +6,7 @@ module atlantafx.base {
requires static org.jetbrains.annotations;
exports atlantafx.base.controls;
exports atlantafx.base.layout;
exports atlantafx.base.theme;
exports atlantafx.base.util;

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@ -8,23 +8,28 @@ See the corresponding [issue](https://github.com/mkpaz/atlantafx/issues/27).
While SceneBuilder does not support adding custom themes, it is possible to overwrite looked-up CSS paths to make the <ins>existing buttons load custom CSS files</ins>.
In order to use AtlantaFX in SceneBuilder you need to:
**(not yet released)**
* Run `mvn package -pl styles` to generate theme CSS files with the correct path names.
* Copy `styles/target/AtlantaFX-${version}-scenebuilder.zip` to the SceneBuilder `$APPDIR` (e.g. `%HOMEPATH%/Local/SceneBuilder/app/` on Windows) or another directory of your choice.
* Open `SceneBuilder.cfg` in the SceneBuilder app directory and add the zip file to the beginning of the `app.classpath` variable, e.g.:
![Project structure](assets/images/scene-builder-integration.png)
### Manual
* Run `mvn package -pl styles` to generate theme package. You can also download it on the [Releases](https://github.com/mkpaz/atlantafx/releases) page.
* Copy `styles/target/AtlantaFX-${version}-scenebuilder.zip` to the SceneBuilder `app/` directory (e.g. `%HOMEPATH%/Local/SceneBuilder/app/` on Windows) or another directory depending on where you installed SceneBuilder application on your PC.
* Open `SceneBuilder.cfg` in the SceneBuilder app directory and add the ZIP file to the beginning of the `app.classpath` variable, e.g.:
```text
app.classpath=$APPDIR\AtlantaFX-${version}-scenebuilder.zip;$APPDIR\scenebuilder-18.0.0-all.jar
# beware about file separator (slash or backslash) depending on your OS
app.classpath=$APPDIR\AtlantaFX-${version}-scenebuilder.zip:$APPDIR\scenebuilder-18.0.0-all.jar
```
* Restart SceneBuilder.
Then you can select AtlantaFX themes in the menu `Preview -> Themes -> Caspian Embedded (FX2)`. The themes are mapped as follows:
| SceneBuilder | Modifier | AtlantaFX Theme |
| SceneBuilder | Caspian High Contrast (FX2) | AtlantaFX Theme |
|-----------------------------|-----------------------------|-----------------|
| Caspian Embedded (FX2) | None | Primer Light |
| Caspian Embedded (FX2) | Caspian High Contrast (FX2) | Primer Dark |
| Caspian Embedded QVGA (FX2) | None | Nord Light |
| Caspian Embedded QVGA (FX2) | Caspian High Contrast (FX2) | Nord Dark |
| Caspian Embedded (FX2) | disabled | Primer Light |
| Caspian Embedded (FX2) | enabled | Primer Dark |
| Caspian Embedded QVGA (FX2) | disabled | Nord Light |
| Caspian Embedded QVGA (FX2) | enabled | Nord Dark |

@ -0,0 +1,430 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import static atlantafx.base.theme.Styles.TEXT_CAPTION;
import static atlantafx.base.theme.Styles.TEXT_MUTED;
import atlantafx.base.controls.Spacer;
import atlantafx.base.layout.DeckPane;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.Resources;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.page.OverlayDialog;
import atlantafx.sampler.page.general.SceneBuilderDialogModel.Screen;
import atlantafx.sampler.util.NodeUtils;
import java.io.File;
import java.net.URI;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.OverrunStyle;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.DirectoryChooser;
import javafx.util.Duration;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import org.kordamp.ikonli.material2.Material2OutlinedAL;
class SceneBuilderDialog extends OverlayDialog<DeckPane> {
private final DeckPane deck;
private final Button backBtn;
private final Button forwardBtn;
private final Button closeBtn;
private Pane startScreen;
private Pane actionScreen;
private Pane themeScreen;
private Pane execScreen;
private Pane reportScreen;
private final SceneBuilderDialogModel model = new SceneBuilderDialogModel();
public SceneBuilderDialog() {
deck = createContent();
backBtn = new Button("Previous", new FontIcon(Material2AL.ARROW_BACK));
forwardBtn = new Button("Next", new FontIcon(Material2AL.ARROW_FORWARD));
forwardBtn.setContentDisplay(ContentDisplay.RIGHT);
closeBtn = new Button("Close");
NodeUtils.toggleVisibility(closeBtn, false);
footerBox.getChildren().setAll(backBtn, new Spacer(), forwardBtn, closeBtn);
setTitle("SceneBuilder Integration");
setContent(deck);
init();
}
private DeckPane createContent() {
startScreen = createStartScreen();
actionScreen = createActionScreen();
themeScreen = createThemeScreen();
execScreen = createExecScreen();
reportScreen = createReportScreen();
var deck = new DeckPane();
deck.addChildren(Insets.EMPTY, startScreen, actionScreen, themeScreen, execScreen, reportScreen);
deck.setAnimationDuration(Duration.millis(250));
deck.setId("scene-builder-wizard");
deck.setPrefSize(600, 440);
return deck;
}
private void init() {
deck.setTopNode(startScreen);
model.activeScreenProperty().addListener((obs, old, val) -> {
if (val == null) {
deck.resetTopNode();
return;
}
var nextScreen = getScreenView(val);
if (old == null || old.ordinal() < val.ordinal()) {
deck.swipeLeft(nextScreen);
} else {
deck.swipeRight(nextScreen);
}
if (val == Screen.REPORT) {
NodeUtils.toggleVisibility(closeBtn, true);
NodeUtils.toggleVisibility(forwardBtn, false);
} else {
NodeUtils.toggleVisibility(closeBtn, false);
NodeUtils.toggleVisibility(forwardBtn, true);
}
});
backBtn.setOnAction(e -> model.back());
backBtn.visibleProperty().bind(model.canGoBackProperty());
forwardBtn.setOnAction(e -> model.forward());
forwardBtn.disableProperty().bind(model.canGoForwardProperty().not());
closeBtn.setOnAction(e -> close());
}
void reset() {
model.reset();
}
private Pane getScreenView(Screen screen) {
return switch (screen) {
case START -> startScreen;
case ACTION -> actionScreen;
case THEME -> themeScreen;
case EXEC -> execScreen;
case REPORT -> reportScreen;
};
}
private Pane createStartScreen() {
var previewImg = new ImageView(new Image(
Resources.getResourceAsStream("images/scene-builder-in-action.jpg")
));
previewImg.setFitWidth(280);
previewImg.setFitHeight(190);
var previewLbl = new Label("""
SceneBuilder is a visual layout tool that lets users quickly \
design JavaFX application user interfaces, without coding.
""");
previewLbl.setWrapText(true);
var downloadLnk = new Hyperlink("Get SceneBuilder");
downloadLnk.setGraphic(new FontIcon(Material2OutlinedAL.LINK));
downloadLnk.setOnAction(e -> DefaultEventBus.getInstance().publish(
new BrowseEvent(URI.create("https://gluonhq.com/products/scene-builder/"))
));
var previewBox = new HBox(20, previewImg, new VBox(20, previewLbl, downloadLnk));
previewBox.setAlignment(Pos.TOP_LEFT);
var browseLbl = new Label("Select SceneBuilder installation directory:");
browseLbl.getStyleClass().addAll(TEXT_CAPTION, TEXT_MUTED);
var browseBtn = new Button("Browse", new FontIcon(Material2OutlinedAL.FOLDER));
browseBtn.setMinWidth(120);
browseBtn.setOnAction(e -> {
var dirChooser = new DirectoryChooser();
File dir = dirChooser.showDialog(getScene().getWindow());
if (dir != null) {
model.setInstallDir(dir.toPath());
}
});
var installDirLbl = new Label();
installDirLbl.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
installDirLbl.textProperty().bind(model.installDirProperty().map(
path -> path.toAbsolutePath().toString()
));
HBox.setHgrow(installDirLbl, Priority.ALWAYS);
var installDirBox = new HBox(10, browseBtn, installDirLbl);
installDirBox.setAlignment(Pos.CENTER_LEFT);
var noticeLbl = new Label("""
You must have write permission to the directory. \
Installation files will be overwritten, but you can rollback changes using the same dialog again.
""");
noticeLbl.setWrapText(true);
// ~
var root = new VBox();
root.getChildren().setAll(
previewBox,
new Spacer(20, Orientation.VERTICAL),
browseLbl,
installDirBox,
noticeLbl
);
root.setAlignment(Pos.CENTER_LEFT);
root.getStyleClass().add("screen");
return root;
}
private Pane createActionScreen() {
var icon = new ImageView(new Image(
Resources.getResourceAsStream("images/question.png")
));
icon.setFitWidth(64);
icon.setFitHeight(64);
var iconBox = new HBox(icon);
iconBox.setAlignment(Pos.CENTER);
var msgLbl = new Label("AtlantaFX theme pack is already installed.");
var updateRadio = new RadioButton("Update or install other themes");
updateRadio.setToggleGroup(model.getActionGroup());
updateRadio.setSelected(true);
updateRadio.setUserData(SceneBuilderDialogModel.ACTION_INSTALL);
var rollbackRadio = new RadioButton("Uninstall AtlantaFX themes from SceneBuilder");
rollbackRadio.setToggleGroup(model.getActionGroup());
rollbackRadio.setUserData(SceneBuilderDialogModel.ACTION_ROLLBACK);
// ~
var root = new VBox();
root.getChildren().setAll(
iconBox,
new Spacer(20, Orientation.VERTICAL),
msgLbl,
updateRadio,
rollbackRadio
);
root.setAlignment(Pos.CENTER_LEFT);
root.getStyleClass().add("screen");
return root;
}
private Pane createThemeScreen() {
var icon = new ImageView(new Image(
Resources.getResourceAsStream("images/color-palette.png")
));
icon.setFitWidth(64);
icon.setFitHeight(64);
var iconBox = new HBox(icon);
iconBox.setAlignment(Pos.CENTER);
var msgLbl = new Label("Please select up to four themes to install.");
final var checkBoxes = new ArrayList<CheckBox>();
final var listener = new ChangeListener<Boolean>() {
private int activeCount = 0;
@Override
public void changed(ObservableValue<? extends Boolean> obs, Boolean old, Boolean val) {
if (val) {
activeCount++;
if (activeCount == model.getSceneBuilderThemes().size() - 1) {
for (var cb : checkBoxes) {
if (!cb.isSelected()) {
cb.setDisable(true);
}
}
}
} else {
if (activeCount == model.getSceneBuilderThemes().size() - 1) {
for (var cb : checkBoxes) {
cb.setDisable(false);
}
}
activeCount--;
}
model.notifyThemeToggleStateChanged();
}
};
model.getThemes().forEach(toggle -> {
var cb = new CheckBox(toggle.getTheme().getName());
cb.selectedProperty().bindBidirectional(toggle.selectedProperty());
cb.setUserData(toggle.getTheme());
cb.setPrefWidth(250); // 2 columns, so half dialog size
cb.selectedProperty().addListener(listener);
checkBoxes.add(cb);
});
var checkBoxPane = new FlowPane(20, 10);
checkBoxPane.getChildren().setAll(checkBoxes);
var root = new VBox();
root.getChildren().setAll(
iconBox,
new Spacer(20, Orientation.VERTICAL),
msgLbl,
checkBoxPane
);
root.setAlignment(Pos.CENTER_LEFT);
root.getStyleClass().add("screen");
return root;
}
private Pane createExecScreen() {
var menuImg = new ImageView(new Image(
Resources.getResourceAsStream("images/scene-builder-themes.png")
));
menuImg.setFitWidth(280);
menuImg.setFitHeight(210);
menuImg.setCursor(Cursor.HAND);
var test = new ImageView(new Image(
Resources.getResourceAsStream("images/scene-builder-themes.png")
));
test.setPickOnBounds(true);
Tooltip tooltip = new Tooltip();
tooltip.setGraphic(test);
Tooltip.install(menuImg, tooltip);
var msgLbl = new Label("""
SceneBuilder doesn't support adding external themes. AtlantaFX themes will \
replace old and unused Caspian stylesheets.
""");
msgLbl.setWrapText(true);
var docsLbl = new Label("You can find more info about the process in the docs.");
docsLbl.setWrapText(true);
var docsLink = new Hyperlink("Documentation");
docsLink.setGraphic(new FontIcon(Material2OutlinedAL.LINK));
docsLink.setOnAction(e -> DefaultEventBus.getInstance().publish(
new BrowseEvent(URI.create("https://mkpaz.github.io/atlantafx/fxml/"))
));
var imageBox = new HBox(20, menuImg, new VBox(20, msgLbl, docsLbl, docsLink));
imageBox.setAlignment(Pos.TOP_LEFT);
var mappingLbl = new Label("After the installation themes will be mapped as follows.");
mappingLbl.getStyleClass().addAll(TEXT_CAPTION, TEXT_MUTED);
final var mappingGrid = new GridPane();
mappingGrid.setHgap(20);
mappingGrid.setVgap(10);
model.themeMapProperty().addListener((obs, old, val) -> {
mappingGrid.getChildren().clear();
if (val != null) {
var idx = new AtomicInteger(0);
val.forEach((k, v) -> {
mappingGrid.add(new Label(k), 0, idx.get());
mappingGrid.add(new FontIcon(Material2AL.EAST), 1, idx.get());
mappingGrid.add(new Label(v), 2, idx.getAndIncrement());
});
}
});
var root = new VBox();
root.getChildren().setAll(
imageBox,
new Spacer(20, Orientation.VERTICAL),
mappingLbl,
mappingGrid
);
root.setAlignment(Pos.CENTER_LEFT);
root.getStyleClass().add("screen");
return root;
}
private Pane createReportScreen() {
var infoIcon = new ImageView(new Image(
Resources.getResourceAsStream("images/info.png")
));
infoIcon.setFitWidth(64);
infoIcon.setFitHeight(64);
var warningIcon = new ImageView(new Image(
Resources.getResourceAsStream("images/warning.png")
));
warningIcon.setFitWidth(64);
warningIcon.setFitHeight(64);
var iconBox = new HBox();
iconBox.setAlignment(Pos.CENTER);
var msgLbl = new Label();
msgLbl.setAlignment(Pos.TOP_CENTER);
msgLbl.getStyleClass().add(Styles.TITLE_4);
msgLbl.setMaxWidth(400);
msgLbl.setWrapText(true);
model.reportProperty().addListener((obs, old, val) -> {
if (val != null) {
iconBox.getChildren().setAll(val.error() ? warningIcon : infoIcon);
msgLbl.setText(val.message());
} else {
iconBox.getChildren().clear();
msgLbl.setText(null);
}
});
// ~
var root = new VBox();
root.getChildren().setAll(
iconBox,
new Spacer(20, Orientation.VERTICAL),
msgLbl
);
root.setAlignment(Pos.CENTER);
root.getStyleClass().add("screen");
return root;
}
}

@ -0,0 +1,340 @@
package atlantafx.sampler.page.general;
import atlantafx.base.theme.PrimerLight;
import atlantafx.sampler.theme.SamplerTheme;
import atlantafx.sampler.theme.SceneBuilderTheme;
import atlantafx.sampler.theme.ThemeManager;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.concurrent.Task;
import javafx.scene.control.ToggleGroup;
import org.jetbrains.annotations.Nullable;
class SceneBuilderDialogModel {
public enum Screen {
// order matters as it determines swipe direction when switching between screens
START, ACTION, THEME, EXEC, REPORT
}
static final String ACTION_INSTALL = "INSTALL";
static final String ACTION_ROLLBACK = "ROLLBACK";
public SceneBuilderDialogModel() {
// default constructor
}
private void changeScreen(@Nullable Transition transition) {
// null value means that forward action was blocked on the previous screen, so no transition required
if (transition == null) {
return;
}
canGoBack.set(transition.canGoBack());
canGoForward.set(transition.canGoForward());
if (transition.action() != null) {
transition.action().run();
}
activeScreen.set(transition.nextScreen());
}
private void updateThemeMapping() {
themeMap.set(getThemeMapping().entrySet().stream().collect(
Collectors.toMap(
e -> e.getKey().name(),
e -> e.getValue().getName(),
(e1, e2) -> e1,
LinkedHashMap::new
)
));
}
private Map<SceneBuilderTheme, SamplerTheme> getThemeMapping() {
var sbIdx = 0;
var map = new LinkedHashMap<SceneBuilderTheme, SamplerTheme>();
for (int idx = 0; idx < themes.size() && sbIdx < sceneBuilderThemes.size(); idx++) {
var samplerTheme = themes.get(idx);
if (samplerTheme.isSelected()) {
var sbTheme = sceneBuilderThemes.get(sbIdx);
map.put(sbTheme, samplerTheme.getTheme());
sbIdx++;
}
}
return map;
}
private void installSelectedThemes() {
Objects.requireNonNull(installer, "SceneBuilder install directory must be selected first.");
var task = new Task<Void>() {
@Override
protected Void call() {
installer.install(getThemeMapping());
return null;
}
};
task.setOnSucceeded(x -> report.set(Report.info(
"AtlantaFX themes successfully installed.\nRestart SceneBuilder to apply changes.",
Screen.START
)));
task.setOnFailed(e -> report.set(Report.error(e.getSource().getException().getMessage(), Screen.EXEC)));
new Thread(task).start();
}
private void uninstallAll() {
Objects.requireNonNull(installer, "SceneBuilder install directory must be selected first.");
var task = new Task<Void>() {
@Override
protected Void call() {
installer.uninstall();
return null;
}
};
task.setOnSucceeded(x -> report.set(Report.info(
"AtlantaFX themes successfully uninstalled.\nRestart SceneBuilder to apply changes.",
Screen.START
)));
task.setOnFailed(e -> report.set(Report.error(e.getSource().getException().getMessage(), Screen.EXEC)));
new Thread(task).start();
}
private void requireSupportedAction() {
String action = (String) actionGroup.getSelectedToggle().getUserData();
if (action == null) {
throw new RuntimeException("Action must be selected.");
}
if (!ACTION_INSTALL.equals(action) && !ACTION_ROLLBACK.equals(action)) {
throw new RuntimeException("Unknown action: \"" + action + "\".");
}
}
private boolean isSelectedAction(String action) {
return action.equals(actionGroup.getSelectedToggle().getUserData());
}
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
private final List<SceneBuilderTheme> sceneBuilderThemes = SceneBuilderTheme.CASPIAN_THEMES;
private final List<ThemeToggle> themes = ThemeManager.getInstance().getRepository().getAll().stream()
.map(t -> new ThemeToggle(t, t.unwrap() instanceof PrimerLight))
.toList();
private final ToggleGroup actionGroup = new ToggleGroup();
private @Nullable SceneBuilderInstaller installer;
private final ReadOnlyObjectWrapper<Map<String, String>> themeMap = new ReadOnlyObjectWrapper<>();
private final ReadOnlyObjectWrapper<Path> installDir = new ReadOnlyObjectWrapper<>();
private final ReadOnlyObjectWrapper<Screen> activeScreen = new ReadOnlyObjectWrapper<>(Screen.START);
private final ReadOnlyObjectWrapper<Report> report = new ReadOnlyObjectWrapper<>(null);
private final ReadOnlyBooleanWrapper canGoBack = new ReadOnlyBooleanWrapper(false);
// block initially until user selects installation directory
private final ReadOnlyBooleanWrapper canGoForward = new ReadOnlyBooleanWrapper(false);
public List<SceneBuilderTheme> getSceneBuilderThemes() {
return sceneBuilderThemes;
}
public List<ThemeToggle> getThemes() {
return themes;
}
public ToggleGroup getActionGroup() {
return actionGroup;
}
public ReadOnlyObjectProperty<Map<String, String>> themeMapProperty() {
return themeMap.getReadOnlyProperty();
}
public ReadOnlyObjectProperty<Path> installDirProperty() {
return installDir.getReadOnlyProperty();
}
public ReadOnlyObjectProperty<Screen> activeScreenProperty() {
return activeScreen.getReadOnlyProperty();
}
public ReadOnlyObjectProperty<Report> reportProperty() {
return report.getReadOnlyProperty();
}
public ReadOnlyBooleanProperty canGoBackProperty() {
return canGoBack.getReadOnlyProperty();
}
public ReadOnlyBooleanProperty canGoForwardProperty() {
return canGoForward.getReadOnlyProperty();
}
///////////////////////////////////////////////////////////////////////////
// Commands //
///////////////////////////////////////////////////////////////////////////
public void setInstallDir(Path path) {
Objects.requireNonNull(path);
installDir.set(path);
installer = new SceneBuilderInstaller(path);
canGoForward.set(true);
}
public void notifyThemeToggleStateChanged() {
var selectedCount = themes.stream()
.filter(ThemeToggle::isSelected)
.count();
canGoForward.set(selectedCount > 0 && selectedCount <= sceneBuilderThemes.size());
}
public void reset() {
// go to the start screen, but keep install dir and form fill
activeScreen.set(Screen.START);
canGoBack.set(false);
canGoForward.set(true);
}
public void back() {
var transition = switch (activeScreen.get()) {
case START -> null;
case ACTION -> new Transition(Screen.START, false, true);
case THEME -> {
Objects.requireNonNull(installer, "SceneBuilder install directory must be selected first.");
yield installer.isThemePackInstalled()
? new Transition(Screen.ACTION, true, true)
: new Transition(Screen.START, false, true);
}
case EXEC -> isSelectedAction(ACTION_INSTALL)
? new Transition(Screen.THEME, true, true)
: new Transition(Screen.ACTION, true, true);
case REPORT -> {
var returnScreen = report.get().returnScreen();
yield new Transition(returnScreen, returnScreen != Screen.START, true, () -> report.set(null));
}
};
changeScreen(transition);
}
public void forward() {
var transition = switch (activeScreen.get()) {
case START -> {
Objects.requireNonNull(installer, "SceneBuilder install directory must be selected first.");
if (!installer.isValidDir()) {
yield new Transition(Screen.REPORT, true, false, () -> report.set(
Report.error("Selected directory doesn't look like SceneBuilder installation directory.")
));
}
if (!installer.hasUserWritePermission()) {
yield new Transition(Screen.REPORT, true, false, () -> report.set(
Report.error("You don't have permission to write into installation directory.")
));
}
yield new Transition(
installer.isThemePackInstalled() ? Screen.ACTION : Screen.THEME, true, true,
() -> actionGroup.selectToggle(actionGroup.getToggles().get(0)) // reset action
);
}
case ACTION -> {
// action must be selected before leaving this screen (fail first)
requireSupportedAction();
if (isSelectedAction(ACTION_ROLLBACK)) {
yield new Transition(Screen.REPORT, true, false, this::uninstallAll);
}
yield new Transition(Screen.THEME, true, true);
}
case THEME -> new Transition(Screen.EXEC, true, true, this::updateThemeMapping);
case EXEC -> {
requireSupportedAction();
if (isSelectedAction(ACTION_INSTALL)) {
yield new Transition(Screen.REPORT, true, false, this::installSelectedThemes);
}
yield null;
}
case REPORT -> null;
};
changeScreen(transition);
}
///////////////////////////////////////////////////////////////////////////
public record Report(String message, Screen returnScreen, boolean error) {
public Report {
Objects.requireNonNull(message);
Objects.requireNonNull(returnScreen);
}
public static Report info(String message) {
return info(message, Screen.EXEC);
}
public static Report info(String message, Screen returnScreen) {
return new Report(message, returnScreen, false);
}
public static Report error(String message) {
return error(message, Screen.START);
}
public static Report error(String message, Screen returnScreen) {
return new Report(message, returnScreen, true);
}
}
public static class ThemeToggle {
private final SamplerTheme theme;
private final BooleanProperty selected = new SimpleBooleanProperty();
public ThemeToggle(SamplerTheme theme, boolean selected) {
this.theme = Objects.requireNonNull(theme);
this.selected.set(selected);
}
public SamplerTheme getTheme() {
return theme;
}
public boolean isSelected() {
return selected.get();
}
public BooleanProperty selectedProperty() {
return selected;
}
}
public record Transition(Screen nextScreen, boolean canGoBack, boolean canGoForward, @Nullable Runnable action) {
public Transition(Screen nextScreen, boolean canGoBack, boolean canGoForward) {
this(nextScreen, canGoBack, canGoForward, null);
}
}
}

@ -0,0 +1,234 @@
package atlantafx.sampler.page.general;
import static java.nio.charset.StandardCharsets.UTF_8;
import atlantafx.sampler.theme.SamplerTheme;
import atlantafx.sampler.theme.SceneBuilderTheme;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
class SceneBuilderInstaller {
private static final String THEME_PACK_FILE_NAME = "atlantafx-scene-builder.zip";
private final Path sceneBuilderDir;
public SceneBuilderInstaller(Path dir) {
this.sceneBuilderDir = Objects.requireNonNull(dir);
}
public boolean hasUserWritePermission() {
return Files.isWritable(getConfigDir()) && Files.isWritable(getConfigFile());
}
public boolean isValidDir() {
var cfgDir = getConfigDir();
var cfgFile = getConfigFile();
return Files.exists(cfgDir) && Files.isDirectory(cfgDir)
&& Files.exists(cfgFile) && Files.isRegularFile(cfgFile);
}
public boolean isThemePackInstalled() {
try {
String cfg = Files.readString(getConfigFile(), UTF_8);
Path themePack = getThemePack();
return cfg.contains(THEME_PACK_FILE_NAME) && Files.exists(themePack) && Files.isRegularFile(themePack);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("StringSplitter")
public void install(Map<SceneBuilderTheme, SamplerTheme> themes) {
Objects.requireNonNull(themes);
if (themes.isEmpty()) {
return;
}
// write theme pack archive to install dir
File zipFile = getThemePack().toFile();
try (var fos = new FileOutputStream(zipFile);
var bos = new BufferedOutputStream(fos);
var out = new ZipOutputStream(bos)) {
var readme = new StringBuilder();
readme
.append("This file was auto-generated by AtlantaFX Sampler v")
.append(System.getProperty("app.version"))
.append(".\n\n")
.append("Installed themes:\n\n");
for (var theme : themes.entrySet()) {
var zipPath = theme.getKey().url();
readme
.append(String.format("%-45s", theme.getKey().name()))
.append(" >> ")
.append(theme.getValue().getName())
.append("\n");
writeToZip(out, zipPath, theme.getValue().getResource().getInputStream());
}
writeToZip(out, "readme.txt", new ByteArrayInputStream(readme.toString().getBytes(UTF_8)));
} catch (Exception e) {
throw new RuntimeException("Unable to write theme pack to the SceneBuilder installation directory.", e);
}
// update config file
try {
List<String> cfgData = Files.readAllLines(getConfigFile(), UTF_8);
// already updated
if (cfgData.stream().anyMatch(s -> s.contains(THEME_PACK_FILE_NAME))) {
return;
}
backupConfig();
ListIterator<String> it = cfgData.listIterator();
while (it.hasNext()) {
var line = it.next();
if (line != null && line.startsWith("app.classpath")) {
var kv = line.split("=");
if (kv.length != 2) {
throw new RuntimeException("Unexpected value in SceneBuilder config file: \"" + line + "\".");
}
it.set(kv[0] + "=$APPDIR" + File.separator + THEME_PACK_FILE_NAME + ":" + kv[1]);
}
}
Files.writeString(
getConfigFile(),
String.join("\n", cfgData), // System.lineSeparator() ?
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
);
} catch (Exception e) {
throw new RuntimeException("Unable to update SceneBuilder config file.", e);
}
}
private void writeToZip(ZipOutputStream out, String zipPath, InputStream in) throws IOException {
var entry = new ZipEntry(zipPath);
try (in) {
out.putNextEntry(entry);
byte[] bytes = new byte[1024];
int count = in.read(bytes);
while (count > -1) {
out.write(bytes, 0, count);
count = in.read(bytes);
}
} finally {
out.closeEntry();
}
}
public void uninstall() {
var cfg = getConfigFile();
var backup = getBackupConfigFile();
// rollback config file
if (Files.exists(backup)) {
// easy way
copyFile(backup, cfg, StandardCopyOption.REPLACE_EXISTING);
} else {
// fallback (probably not needed, but should do no harm)
try {
List<String> cfgData = Files.readAllLines(getConfigFile(), UTF_8);
// not present
if (cfgData.stream().noneMatch(s -> s.contains(THEME_PACK_FILE_NAME))) {
return;
}
ListIterator<String> it = cfgData.listIterator();
while (it.hasNext()) {
var line = it.next();
if (line != null && line.startsWith("app.classpath")) {
it.set(line.replace("$APPDIR" + File.separator + THEME_PACK_FILE_NAME + ":", ""));
}
}
Files.writeString(
getConfigFile(),
String.join("\n", cfgData), // System.lineSeparator() ?
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING
);
} catch (Exception e) {
throw new RuntimeException("Unable to update SceneBuilder config file.", e);
}
}
// remove theme pack and backup
deleteFile(getThemePack());
deleteFile(backup);
}
void backupConfig() {
var cfg = getConfigFile();
var backup = getBackupConfigFile();
if (!Files.exists(backup)) {
copyFile(cfg, backup);
}
}
private void copyFile(Path source, Path dest, StandardCopyOption... options) {
Objects.requireNonNull(source);
Objects.requireNonNull(dest);
try {
Files.copy(source, dest, options);
} catch (IOException e) {
throw new RuntimeException("Unable to copy \"" + source + "\" to \"" + dest + "\".", e);
}
}
public static void deleteFile(Path path) {
Objects.requireNonNull(path);
try {
if (Files.exists(path)) {
Files.delete(path);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private Path getConfigDir() {
return sceneBuilderDir.resolve("lib/app");
}
private Path getConfigFile() {
return getConfigDir().resolve("SceneBuilder.cfg");
}
private Path getBackupConfigFile() {
return getConfigDir().resolve("SceneBuilder.cfg.atlantafx.backup");
}
private Path getThemePack() {
return getConfigDir().resolve(THEME_PACK_FILE_NAME);
}
}

@ -11,6 +11,7 @@ import static atlantafx.sampler.page.SampleBlock.BLOCK_VGAP;
import static atlantafx.sampler.util.Controls.hyperlink;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.Resources;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.page.AbstractPage;
@ -25,6 +26,8 @@ import javafx.scene.control.Button;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
@ -38,7 +41,11 @@ import org.kordamp.ikonli.material2.Material2OutlinedMZ;
public class ThemePage extends AbstractPage {
public static final String NAME = "Theme";
private static final ThemeManager TM = ThemeManager.getInstance();
private static final Image SCENE_BUILDER_ICON = new Image(
Resources.getResourceAsStream("images/scene-builder_32.png")
);
private final Consumer<ColorPaletteBlock> colorBlockActionHandler = colorBlock -> {
ContrastCheckerDialog dialog = getOrCreateContrastCheckerDialog();
@ -58,6 +65,7 @@ public class ThemePage extends AbstractPage {
private ThemeRepoManagerDialog themeRepoManagerDialog;
private ContrastCheckerDialog contrastCheckerDialog;
private SceneBuilderDialog sceneBuilderDialog;
@Override
public String getName() {
@ -130,6 +138,14 @@ public class ThemePage extends AbstractPage {
var accentSelector = new AccentColorSelector();
var sceneBuilderBtn = new Button("SceneBuilder Integration");
sceneBuilderBtn.setGraphic(new ImageView(SCENE_BUILDER_ICON));
sceneBuilderBtn.setOnAction(e -> {
SceneBuilderDialog dialog = getOrCreateScneBuilderDialog();
overlay.setContent(dialog, HPos.CENTER);
overlay.toFront();
});
// ~
var grid = new GridPane();
@ -141,6 +157,7 @@ public class ThemePage extends AbstractPage {
grid.add(themeRepoBtn, 2, 0);
grid.add(new Label("Accent color"), 0, 1);
grid.add(accentSelector, 1, 1);
grid.add(sceneBuilderBtn, 0, 2, GridPane.REMAINING, 1);
return grid;
}
@ -208,4 +225,18 @@ public class ThemePage extends AbstractPage {
return contrastCheckerDialog;
}
private SceneBuilderDialog getOrCreateScneBuilderDialog() {
if (sceneBuilderDialog == null) {
sceneBuilderDialog = new SceneBuilderDialog();
}
sceneBuilderDialog.setOnCloseRequest(() -> {
overlay.removeContent();
overlay.toBack();
sceneBuilderDialog.reset();
});
return sceneBuilderDialog;
}
}

@ -84,7 +84,7 @@ public final class SamplerTheme implements Theme {
// any external file path must have "file://" prefix
@Override
public String getUserAgentStylesheet() {
return IS_DEV_MODE ? DUMMY_STYLESHEET : getThemeFile().toURI().toString();
return IS_DEV_MODE ? DUMMY_STYLESHEET : getResource().toURI().toString();
}
@Override
@ -93,7 +93,7 @@ public final class SamplerTheme implements Theme {
}
public Set<String> getAllStylesheets() {
return IS_DEV_MODE ? merge(getThemeFile().toURI().toString(), APP_STYLESHEETS) : Set.of(APP_STYLESHEETS);
return IS_DEV_MODE ? merge(getResource().toURI().toString(), APP_STYLESHEETS) : Set.of(APP_STYLESHEETS);
}
// Checks whether wrapped theme is a project theme or user external theme.
@ -105,7 +105,7 @@ public final class SamplerTheme implements Theme {
// - minified CSS files are not supported
// - only first PARSE_LIMIT lines will be read
public Map<String, String> parseColors() throws IOException {
FileResource file = getThemeFile();
FileResource file = getResource();
return file.internal() ? parseColorsForClasspath(file) : parseColorsForFilesystem(file);
}
@ -162,10 +162,10 @@ public final class SamplerTheme implements Theme {
}
public String getPath() {
return getThemeFile().toPath().toString();
return getResource().toPath().toString();
}
private FileResource getThemeFile() {
public FileResource getResource() {
if (!isProjectTheme()) {
return FileResource.createExternal(theme.getUserAgentStylesheet());
}

@ -0,0 +1,31 @@
package atlantafx.sampler.theme;
import java.util.List;
import java.util.Objects;
public record SceneBuilderTheme(String name, String url) {
public static final List<SceneBuilderTheme> CASPIAN_THEMES = List.of(
new SceneBuilderTheme(
"Caspian Embedded (FX2)",
"com/oracle/javafx/scenebuilder/kit/util/css/caspian/caspian-embedded.css"
),
new SceneBuilderTheme(
"Caspian Embedded QVGA (FX2)",
"com/oracle/javafx/scenebuilder/kit/util/css/caspian/caspian-embedded-qvga.css"
),
new SceneBuilderTheme(
"Caspian High Contrast Embedded (FX2)",
"com/oracle/javafx/scenebuilder/kit/util/css/caspian/caspian-embedded-highContrast.css"
),
new SceneBuilderTheme(
"Caspian High Contrast Embedded QVGA (FX2)",
"com/oracle/javafx/scenebuilder/kit/util/css/caspian/caspian-embedded-qvga-highContrast.css"
)
);
public SceneBuilderTheme {
Objects.requireNonNull(name);
Objects.requireNonNull(url);
}
}

@ -2,4 +2,5 @@
@use "fonts";
@use "root";
@use "util";
@use "scene-builder-wizard";
@use "util";

@ -0,0 +1,7 @@
#scene-builder-wizard {
.screen {
-fx-padding: 10px 20px 10px 20px;
-fx-spacing: 10px;
-fx-background-color: -color-bg-default;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@ -0,0 +1,29 @@
package atlantafx.sampler.page.general;
import atlantafx.base.theme.PrimerDark;
import atlantafx.base.theme.PrimerLight;
import atlantafx.sampler.theme.SamplerTheme;
import atlantafx.sampler.theme.SceneBuilderTheme;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
public class SceneBuilderInstallerTest {
private static final Path INSTALL_DIR = Paths.get("/opt/scenebuilder");
//@Test
public void testInstall() {
var installer = new SceneBuilderInstaller(INSTALL_DIR);
installer.install(Map.of(
SceneBuilderTheme.CASPIAN_THEMES.get(0), new SamplerTheme(new PrimerLight()),
SceneBuilderTheme.CASPIAN_THEMES.get(1), new SamplerTheme(new PrimerDark())
));
}
//@Test
public void testUninstall() {
var installer = new SceneBuilderInstaller(INSTALL_DIR);
installer.uninstall();
}
}