diff --git a/CHANGELOG.md b/CHANGELOG.md index 3619b0e..03e76c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/base/src/main/java/module-info.java b/base/src/main/java/module-info.java index d56c55c..43d017d 100755 --- a/base/src/main/java/module-info.java +++ b/base/src/main/java/module-info.java @@ -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; diff --git a/docs/docs/assets/images/scene-builder-integration.png b/docs/docs/assets/images/scene-builder-integration.png new file mode 100644 index 0000000..12217cb Binary files /dev/null and b/docs/docs/assets/images/scene-builder-integration.png differ diff --git a/docs/docs/fxml.md b/docs/docs/fxml.md index af06c45..ec51b8e 100644 --- a/docs/docs/fxml.md +++ b/docs/docs/fxml.md @@ -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 existing buttons load custom CSS files. -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 | diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialog.java b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialog.java new file mode 100644 index 0000000..00ad4c9 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialog.java @@ -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 { + + 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(); + final var listener = new ChangeListener() { + + private int activeCount = 0; + + @Override + public void changed(ObservableValue 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; + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialogModel.java b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialogModel.java new file mode 100644 index 0000000..16c5e95 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderDialogModel.java @@ -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 getThemeMapping() { + var sbIdx = 0; + var map = new LinkedHashMap(); + + 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() { + @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() { + @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 sceneBuilderThemes = SceneBuilderTheme.CASPIAN_THEMES; + + private final List 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> themeMap = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyObjectWrapper installDir = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyObjectWrapper activeScreen = new ReadOnlyObjectWrapper<>(Screen.START); + private final ReadOnlyObjectWrapper 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 getSceneBuilderThemes() { + return sceneBuilderThemes; + } + + public List getThemes() { + return themes; + } + + public ToggleGroup getActionGroup() { + return actionGroup; + } + + public ReadOnlyObjectProperty> themeMapProperty() { + return themeMap.getReadOnlyProperty(); + } + + public ReadOnlyObjectProperty installDirProperty() { + return installDir.getReadOnlyProperty(); + } + + public ReadOnlyObjectProperty activeScreenProperty() { + return activeScreen.getReadOnlyProperty(); + } + + public ReadOnlyObjectProperty 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); + } + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderInstaller.java b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderInstaller.java new file mode 100644 index 0000000..814deb7 --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/page/general/SceneBuilderInstaller.java @@ -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 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 cfgData = Files.readAllLines(getConfigFile(), UTF_8); + + // already updated + if (cfgData.stream().anyMatch(s -> s.contains(THEME_PACK_FILE_NAME))) { + return; + } + + backupConfig(); + + ListIterator 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 cfgData = Files.readAllLines(getConfigFile(), UTF_8); + + // not present + if (cfgData.stream().noneMatch(s -> s.contains(THEME_PACK_FILE_NAME))) { + return; + } + + ListIterator 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); + } +} diff --git a/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java b/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java index 3d4d734..5bb0eef 100755 --- a/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java +++ b/sampler/src/main/java/atlantafx/sampler/page/general/ThemePage.java @@ -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 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; + } } diff --git a/sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java b/sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java index 0f1f696..289c52a 100644 --- a/sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java +++ b/sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java @@ -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 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 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()); } diff --git a/sampler/src/main/java/atlantafx/sampler/theme/SceneBuilderTheme.java b/sampler/src/main/java/atlantafx/sampler/theme/SceneBuilderTheme.java new file mode 100644 index 0000000..e1e86ae --- /dev/null +++ b/sampler/src/main/java/atlantafx/sampler/theme/SceneBuilderTheme.java @@ -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 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); + } +} \ No newline at end of file diff --git a/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_index.scss b/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_index.scss index 8c25206..076ec59 100644 --- a/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_index.scss +++ b/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_index.scss @@ -2,4 +2,5 @@ @use "fonts"; @use "root"; -@use "util"; \ No newline at end of file +@use "scene-builder-wizard"; +@use "util"; diff --git a/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_scene-builder-wizard.scss b/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_scene-builder-wizard.scss new file mode 100644 index 0000000..7b2d6b5 --- /dev/null +++ b/sampler/src/main/resources/atlantafx/sampler/assets/styles/scss/general/_scene-builder-wizard.scss @@ -0,0 +1,7 @@ +#scene-builder-wizard { + .screen { + -fx-padding: 10px 20px 10px 20px; + -fx-spacing: 10px; + -fx-background-color: -color-bg-default; + } +} diff --git a/sampler/src/main/resources/atlantafx/sampler/images/color-palette.png b/sampler/src/main/resources/atlantafx/sampler/images/color-palette.png new file mode 100644 index 0000000..af5d344 Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/color-palette.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/info.png b/sampler/src/main/resources/atlantafx/sampler/images/info.png new file mode 100644 index 0000000..d687dda Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/info.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/question.png b/sampler/src/main/resources/atlantafx/sampler/images/question.png new file mode 100644 index 0000000..f9d0f1f Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/question.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-in-action.jpg b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-in-action.jpg new file mode 100644 index 0000000..c28bb67 Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-in-action.jpg differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-themes.png b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-themes.png new file mode 100644 index 0000000..b681161 Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder-themes.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/scene-builder.png b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder.png new file mode 100644 index 0000000..9daab06 Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/scene-builder_32.png b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder_32.png new file mode 100644 index 0000000..c872ecb Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/scene-builder_32.png differ diff --git a/sampler/src/main/resources/atlantafx/sampler/images/warning.png b/sampler/src/main/resources/atlantafx/sampler/images/warning.png new file mode 100644 index 0000000..0883097 Binary files /dev/null and b/sampler/src/main/resources/atlantafx/sampler/images/warning.png differ diff --git a/sampler/src/test/java/atlantafx/sampler/page/general/SceneBuilderInstallerTest.java b/sampler/src/test/java/atlantafx/sampler/page/general/SceneBuilderInstallerTest.java new file mode 100644 index 0000000..acfe8f9 --- /dev/null +++ b/sampler/src/test/java/atlantafx/sampler/page/general/SceneBuilderInstallerTest.java @@ -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(); + } +} \ No newline at end of file