Improve Sampler layout

- Refactor to MVVM.
- Add fancy top bar.
- Add keyboard navigation for sidebar.
- Add hotkeys support.
This commit is contained in:
mkpaz 2022-09-19 16:22:01 +04:00
parent 1c46a7a5d5
commit 978577dc6a
19 changed files with 775 additions and 421 deletions

@ -3,6 +3,7 @@ package atlantafx.sampler;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.HotkeyEvent;
import atlantafx.sampler.event.Listener;
import atlantafx.sampler.layout.ApplicationWindow;
import atlantafx.sampler.theme.ThemeManager;
@ -14,12 +15,17 @@ import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import javafx.scene.input.KeyEvent;
import javafx.stage.Stage;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.nio.file.Paths;
import java.util.List;
import java.util.Properties;
import static java.nio.charset.StandardCharsets.UTF_8;
@ -30,6 +36,10 @@ public class Launcher extends Application {
Resources.getPropertyOrEnv("atlantafx.mode", "ATLANTAFX_MODE")
);
public static final List<KeyCodeCombination> SUPPORTED_HOTKEYS = List.of(
new KeyCodeCombination(KeyCode.F, KeyCombination.CONTROL_DOWN)
);
private static class DefaultExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
@ -45,14 +55,15 @@ public class Launcher extends Application {
@Override
public void start(Stage stage) {
Thread.currentThread().setUncaughtExceptionHandler(new DefaultExceptionHandler());
loadApplicationProperties();
if (IS_DEV_MODE) {
System.out.println("[WARNING] Application is running in development mode.");
}
loadApplicationProperties();
var root = new ApplicationWindow();
var scene = new Scene(root, 1200, 768);
scene.setOnKeyPressed(this::dispatchHotkeys);
var tm = ThemeManager.getInstance();
tm.setScene(scene);
@ -119,6 +130,15 @@ public class Launcher extends Application {
CSSFX.start(scene);
}
private void dispatchHotkeys(KeyEvent event) {
for (KeyCodeCombination k : SUPPORTED_HOTKEYS) {
if (k.match(event)) {
DefaultEventBus.getInstance().publish(new HotkeyEvent(k));
return;
}
}
}
@Listener
private void onBrowseEvent(BrowseEvent event) {
getHostServices().showDocument(event.getUri().toString());

@ -19,6 +19,6 @@ public class BrowseEvent extends Event {
public String toString() {
return "BrowseEvent{" +
"uri=" + uri +
'}';
"} " + super.toString();
}
}

@ -0,0 +1,24 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.event;
import javafx.scene.input.KeyCodeCombination;
public class HotkeyEvent extends Event {
private final KeyCodeCombination keys;
public HotkeyEvent(KeyCodeCombination keys) {
this.keys = keys;
}
public KeyCodeCombination getKeys() {
return keys;
}
@Override
public String toString() {
return "HotkeyEvent{" +
"keys=" + keys +
"} " + super.toString();
}
}

@ -23,4 +23,11 @@ public class ThemeEvent extends Event {
THEME_ADD,
THEME_REMOVE
}
@Override
public String toString() {
return "ThemeEvent{" +
"eventType=" + eventType +
"} " + super.toString();
}
}

@ -0,0 +1,136 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.layout;
import atlantafx.base.controls.CustomTextField;
import atlantafx.base.controls.Spacer;
import atlantafx.sampler.event.BrowseEvent;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.HotkeyEvent;
import javafx.beans.binding.Bindings;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCombination.ModifierValue;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import org.kordamp.ikonli.Ikon;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2MZ;
import org.kordamp.ikonli.material2.Material2OutlinedMZ;
import java.net.URI;
import java.util.function.Consumer;
import static atlantafx.base.theme.Styles.*;
import static atlantafx.sampler.Launcher.IS_DEV_MODE;
import static atlantafx.sampler.layout.MainLayer.SIDEBAR_WIDTH;
class HeaderBar extends HBox {
private static final int HEADER_HEIGHT = 50;
private static final Ikon ICON_CODE = Feather.CODE;
private static final Ikon ICON_SAMPLE = Feather.LAYOUT;
private final MainModel model;
private Consumer<Node> quickConfigActionHandler;
public HeaderBar(MainModel model) {
super();
this.model = model;
createView();
}
private void createView() {
var logoLabel = new Label("AtlantaFX");
logoLabel.getStyleClass().addAll(TITLE_3);
var versionLabel = new Label(System.getProperty("app.version"));
versionLabel.getStyleClass().addAll("version", TEXT_SMALL);
var githubLink = new FontIcon(Feather.GITHUB);
githubLink.getStyleClass().addAll("github");
githubLink.setOnMouseClicked(e -> {
var homepage = System.getProperty("app.homepage");
if (homepage != null) {
DefaultEventBus.getInstance().publish(new BrowseEvent(URI.create(homepage)));
}
});
var logoBox = new HBox(10, logoLabel, versionLabel, new Spacer(), githubLink);
logoBox.getStyleClass().add("logo");
logoBox.setAlignment(Pos.CENTER_LEFT);
logoBox.setMinWidth(SIDEBAR_WIDTH);
logoBox.setPrefWidth(SIDEBAR_WIDTH);
logoBox.setMaxWidth(SIDEBAR_WIDTH);
var titleLabel = new Label();
titleLabel.getStyleClass().addAll("page-title", TITLE_4);
titleLabel.textProperty().bind(model.titleProperty());
var searchField = new CustomTextField();
searchField.setLeft(new FontIcon(Material2MZ.SEARCH));
searchField.setPromptText("Search");
model.searchTextProperty().bind(searchField.textProperty());
DefaultEventBus.getInstance().subscribe(HotkeyEvent.class, e -> {
if (e.getKeys().getControl() == ModifierValue.DOWN && e.getKeys().getCode() == KeyCode.F) {
searchField.requestFocus();
}
});
// this dummy anchor prevents popover from inheriting
// unwanted styles from the owner node
var popoverAnchor = new Region();
var quickConfigBtn = new FontIcon(Material2OutlinedMZ.STYLE);
quickConfigBtn.mouseTransparentProperty().bind(model.themeChangeToggleProperty().not());
quickConfigBtn.opacityProperty().bind(Bindings.createDoubleBinding(
() -> model.themeChangeToggleProperty().get() ? 1.0 : 0.5, model.themeChangeToggleProperty()
));
quickConfigBtn.setOnMouseClicked(e -> {
if (quickConfigActionHandler != null) { quickConfigActionHandler.accept(popoverAnchor); }
});
var sourceCodeBtn = new FontIcon(ICON_CODE);
sourceCodeBtn.mouseTransparentProperty().bind(model.sourceCodeToggleProperty().not());
sourceCodeBtn.opacityProperty().bind(Bindings.createDoubleBinding(
() -> model.sourceCodeToggleProperty().get() ? 1.0 : 0.5, model.sourceCodeToggleProperty()
));
sourceCodeBtn.setOnMouseClicked(e -> model.nextSubLayer());
// ~
model.currentSubLayerProperty().addListener((obs, old, val) -> {
switch (val) {
case PAGE -> sourceCodeBtn.setIconCode(ICON_CODE);
case SOURCE_CODE -> sourceCodeBtn.setIconCode(ICON_SAMPLE);
}
});
setId("header-bar");
setMinHeight(HEADER_HEIGHT);
setPrefHeight(HEADER_HEIGHT);
setAlignment(Pos.CENTER_LEFT);
getChildren().setAll(
logoBox,
titleLabel,
new Spacer(),
searchField,
popoverAnchor,
quickConfigBtn,
sourceCodeBtn
);
if (IS_DEV_MODE) {
var devModeLabel = new Label("app is running in development mode");
devModeLabel.getStyleClass().addAll(TEXT_SMALL, "dev-mode-indicator");
getChildren().add(2, devModeLabel);
}
}
void setQuickConfigActionHandler(Consumer<Node> handler) {
this.quickConfigActionHandler = handler;
}
}

@ -1,42 +1,114 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.layout;
import atlantafx.base.controls.Popover;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.layout.MainModel.SubLayer;
import atlantafx.sampler.page.CodeViewer;
import atlantafx.sampler.page.Page;
import atlantafx.sampler.page.QuickConfigMenu;
import atlantafx.sampler.page.components.OverviewPage;
import atlantafx.sampler.theme.ThemeManager;
import javafx.animation.FadeTransition;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import java.io.IOException;
import java.util.Objects;
import static atlantafx.base.controls.Popover.ArrowLocation.TOP_CENTER;
import static javafx.scene.layout.Priority.ALWAYS;
class MainLayer extends BorderPane {
static final int SIDEBAR_WIDTH = 220;
static final int PAGE_TRANSITION_DURATION = 500; // ms
private final MainModel model = new MainModel();
private final HeaderBar headerBar = new HeaderBar(model);
private final Sidebar sidebar = new Sidebar(model);
private final StackPane subLayerPane = new StackPane();
private Popover quickConfigPopover;
private CodeViewer codeViewer;
private StackPane codeViewerWrapper;
public MainLayer() {
super();
createView();
initListeners();
model.navigate(OverviewPage.class);
// keyboard navigation won't work without focus
Platform.runLater(sidebar::begForFocus);
}
private void createView() {
var sidebar = new Sidebar();
sidebar.setMinWidth(200);
sidebar.setMinWidth(SIDEBAR_WIDTH);
final var pageContainer = new StackPane();
HBox.setHgrow(pageContainer, ALWAYS);
codeViewer = new CodeViewer();
sidebar.setOnSelect(pageClass -> {
codeViewerWrapper = new StackPane();
codeViewerWrapper.getStyleClass().add("source-code");
codeViewerWrapper.getChildren().setAll(codeViewer);
subLayerPane.getChildren().setAll(codeViewerWrapper);
HBox.setHgrow(subLayerPane, ALWAYS);
// ~
setId("main");
setTop(headerBar);
setLeft(sidebar);
setCenter(subLayerPane);
}
private void initListeners() {
headerBar.setQuickConfigActionHandler(this::showThemeConfigPopover);
model.selectedPageProperty().addListener((obs, old, val) -> {
if (val != null) { loadPage(val); }
});
model.currentSubLayerProperty().addListener((obs, old, val) -> {
switch (val) {
case PAGE -> hideSourceCode();
case SOURCE_CODE -> showSourceCode();
}
});
// update code view color theme on app theme change
DefaultEventBus.getInstance().subscribe(ThemeEvent.class, e -> {
if (ThemeManager.getInstance().getTheme() != null && model.currentSubLayerProperty().get() == SubLayer.SOURCE_CODE) {
showSourceCode();
}
});
}
private void loadPage(Class<? extends Page> pageClass) {
try {
final Page prevPage = (!pageContainer.getChildren().isEmpty() && pageContainer.getChildren().get(0) instanceof Page page) ? page : null;
final Page prevPage = (Page) subLayerPane.getChildren().stream()
.filter(c -> c instanceof Page)
.findFirst()
.orElse(null);
final Page nextPage = pageClass.getDeclaredConstructor().newInstance();
// startup, no animation
model.setPageData(
nextPage.getName(),
nextPage.canChangeThemeSettings(),
nextPage.canDisplaySourceCode()
);
// startup, no prev page, no animation
if (getScene() == null) {
pageContainer.getChildren().add(nextPage.getView());
subLayerPane.getChildren().add(nextPage.getView());
return;
}
@ -46,22 +118,51 @@ class MainLayer extends BorderPane {
prevPage.reset();
// animate switching between pages
pageContainer.getChildren().add(nextPage.getView());
FadeTransition transition = new FadeTransition(Duration.millis(300), nextPage.getView());
subLayerPane.getChildren().add(nextPage.getView());
var transition = new FadeTransition(Duration.millis(PAGE_TRANSITION_DURATION), nextPage.getView());
transition.setFromValue(0.0);
transition.setToValue(1.0);
transition.setOnFinished(t -> pageContainer.getChildren().remove(prevPage.getView()));
transition.setOnFinished(t -> {
subLayerPane.getChildren().remove(prevPage.getView());
if (nextPage instanceof Pane nextPane) { nextPane.toFront(); }
});
transition.play();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
// ~
setLeft(sidebar);
setCenter(pageContainer);
private void showThemeConfigPopover(Node source) {
if (quickConfigPopover == null) {
var content = new QuickConfigMenu();
content.setExitHandler(() -> quickConfigPopover.hide());
sidebar.select(OverviewPage.class);
Platform.runLater(sidebar::requestFocus);
quickConfigPopover = new Popover(content);
quickConfigPopover.setHeaderAlwaysVisible(false);
quickConfigPopover.setDetachable(false);
quickConfigPopover.setArrowLocation(TOP_CENTER);
quickConfigPopover.setOnShowing(e -> content.update());
}
quickConfigPopover.show(source);
}
private void showSourceCode() {
var sourceClass = Objects.requireNonNull(model.selectedPageProperty().get());
var sourceFileName = sourceClass.getSimpleName() + ".java";
try (var stream = sourceClass.getResourceAsStream(sourceFileName)) {
Objects.requireNonNull(stream, "Missing source file '" + sourceFileName + "';");
// set syntax highlight theme according to JavaFX theme
ThemeManager tm = ThemeManager.getInstance();
codeViewer.setContent(stream, tm.getMatchingSourceCodeHighlightTheme(tm.getTheme()));
codeViewerWrapper.toFront();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void hideSourceCode() {
codeViewerWrapper.toBack();
}
}

@ -0,0 +1,73 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.layout;
import atlantafx.sampler.page.Page;
import javafx.beans.property.*;
import java.util.Objects;
import static atlantafx.sampler.layout.MainModel.SubLayer.PAGE;
import static atlantafx.sampler.layout.MainModel.SubLayer.SOURCE_CODE;
public class MainModel {
public enum SubLayer {
PAGE,
SOURCE_CODE
}
private final ReadOnlyStringWrapper title = new ReadOnlyStringWrapper();
private final StringProperty searchText = new SimpleStringProperty();
private final ReadOnlyObjectWrapper<Class<? extends Page>> selectedPage = new ReadOnlyObjectWrapper<>();
private final ReadOnlyBooleanWrapper themeChangeToggle = new ReadOnlyBooleanWrapper();
private final ReadOnlyBooleanWrapper sourceCodeToggle = new ReadOnlyBooleanWrapper();
private final ReadOnlyObjectWrapper<SubLayer> currentSubLayer = new ReadOnlyObjectWrapper<>(PAGE);
///////////////////////////////////////////////////////////////////////////
// Properties //
///////////////////////////////////////////////////////////////////////////
public StringProperty searchTextProperty() {
return searchText;
}
public ReadOnlyStringProperty titleProperty() {
return title.getReadOnlyProperty();
}
public ReadOnlyBooleanProperty themeChangeToggleProperty() {
return themeChangeToggle.getReadOnlyProperty();
}
public ReadOnlyBooleanProperty sourceCodeToggleProperty() {
return sourceCodeToggle.getReadOnlyProperty();
}
public ReadOnlyObjectProperty<Class<? extends Page>> selectedPageProperty() {
return selectedPage.getReadOnlyProperty();
}
public ReadOnlyObjectProperty<SubLayer> currentSubLayerProperty() {
return currentSubLayer.getReadOnlyProperty();
}
///////////////////////////////////////////////////////////////////////////
// Commands //
///////////////////////////////////////////////////////////////////////////
public void setPageData(String text, boolean canChangeTheme, boolean canDisplaySource) {
title.set(Objects.requireNonNull(text));
themeChangeToggle.set(canChangeTheme);
sourceCodeToggle.set(canDisplaySource);
}
public void navigate(Class<? extends Page> page) {
selectedPage.set(Objects.requireNonNull(page));
currentSubLayer.set(PAGE);
}
public void nextSubLayer() {
var old = currentSubLayer.get();
currentSubLayer.set(old == PAGE ? SOURCE_CODE : PAGE);
}
}

@ -11,104 +11,164 @@ import atlantafx.sampler.page.showcase.filemanager.FileManagerPage;
import atlantafx.sampler.page.showcase.musicplayer.MusicPlayerPage;
import atlantafx.sampler.util.Containers;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.css.PseudoClass;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.*;
import java.util.function.Predicate;
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED;
import static javafx.scene.layout.Priority.ALWAYS;
class Sidebar extends VBox {
class Sidebar extends StackPane {
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private static final PseudoClass FILTERED = PseudoClass.getPseudoClass("filtered");
private static final Predicate<Region> PREDICATE_ANY = region -> true;
private final FilteredList<Region> navigationMenu = navigationMenu();
private final ReadOnlyObjectWrapper<NavLink> selectedLink = new ReadOnlyObjectWrapper<>();
private Consumer<Class<? extends Page>> navigationHandler;
private final MainModel model;
private final NavMenu navMenu;
private ScrollPane navScroll;
public Sidebar() {
public Sidebar(MainModel model) {
super();
setId("sidebar");
this.model = model;
this.navMenu = new NavMenu(model);
createView();
selectedLink.addListener((obs, old, val) -> {
if (navigationHandler != null) {
navigationHandler.accept(val != null ? val.getPageClass() : null);
}
});
}
public void select(Class<? extends Page> pageClass) {
navigationMenu.stream()
.filter(region -> region instanceof NavLink link && pageClass.equals(link.getPageClass()))
.findFirst()
.ifPresent(link -> navigate((NavLink) link));
}
public void setOnSelect(Consumer<Class<? extends Page>> c) {
navigationHandler = c;
}
private void createView() {
var placeholder = new Label("No content");
placeholder.getStyleClass().add(Styles.TITLE_4);
var navContainer = new VBox();
navContainer.getStyleClass().add("nav-menu");
Bindings.bindContent(navContainer.getChildren(), navigationMenu);
Bindings.bindContent(navContainer.getChildren(), navMenu.getContent());
var navScroll = new ScrollPane(navContainer);
Containers.setScrollConstraints(navScroll,
ScrollPane.ScrollBarPolicy.AS_NEEDED, true,
ScrollPane.ScrollBarPolicy.AS_NEEDED, true
);
navScroll = new ScrollPane(navContainer);
Containers.setScrollConstraints(navScroll, AS_NEEDED, true, AS_NEEDED, true);
VBox.setVgrow(navScroll, ALWAYS);
// == SEARCH FORM ==
var searchField = new TextField();
searchField.setPromptText("Search");
HBox.setHgrow(searchField, ALWAYS);
searchField.textProperty().addListener((obs, old, val) -> {
if (val == null || val.isBlank()) {
navigationMenu.setPredicate(c -> true);
return;
}
navigationMenu.setPredicate(c -> c instanceof NavLink link && link.matches(val));
model.searchTextProperty().addListener((obs, old, val) -> {
var empty = val == null || val.isBlank();
pseudoClassStateChanged(FILTERED, !empty);
navMenu.setPredicate(empty ? PREDICATE_ANY : region -> region instanceof NavLink link && link.matches(val));
});
var searchForm = new HBox(searchField);
searchForm.setId("search-form");
searchForm.setAlignment(Pos.CENTER_LEFT);
model.selectedPageProperty().addListener((obs, old, val) -> {
navMenu.findLink(old).ifPresent(link -> link.pseudoClassStateChanged(SELECTED, false));
navMenu.findLink(val).ifPresent(link -> link.pseudoClassStateChanged(SELECTED, true));
});
// ~
navScroll.addEventFilter(KeyEvent.KEY_PRESSED, e -> {
var offset = 1 / (navContainer.getHeight() - navScroll.getViewportBounds().getHeight());
if (e.getCode() == KeyCode.UP) {
navMenu.getPrevious().ifPresentOrElse(link -> {
navScroll.setVvalue(link.getLayoutY() * offset / 2);
model.navigate(link.getPageClass());
}, () -> navScroll.setVvalue(0));
e.consume();
}
if (e.getCode() == KeyCode.DOWN) {
navMenu.getNext().ifPresentOrElse(link -> {
navScroll.setVvalue(link.getLayoutY() * offset / 2);
model.navigate(link.getPageClass());
}, () -> navScroll.setVvalue(1.0));
e.consume();
}
});
getChildren().addAll(searchForm, navScroll);
navMenu.getContent().addListener((ListChangeListener<Region>) c -> {
if (navMenu.getContent().isEmpty()) {
placeholder.toFront();
} else {
placeholder.toBack();
}
});
setId("sidebar");
getChildren().addAll(placeholder, navScroll);
}
private Label caption(String text) {
var label = new Label(text);
label.getStyleClass().add("caption");
label.setMaxWidth(Double.MAX_VALUE);
return label;
void begForFocus() {
navScroll.requestFocus();
}
private FilteredList<Region> navigationMenu() {
return new FilteredList<>(FXCollections.observableArrayList(
///////////////////////////////////////////////////////////////////////////
private static class NavMenu {
private final MainModel model;
private final FilteredList<Region> content;
private final Map<Class<? extends Page>, NavLink> registry = new HashMap<>();
public NavMenu(MainModel model) {
var links = create();
this.model = model;
this.content = new FilteredList<>(links);
links.forEach(c -> {
if (c instanceof NavLink link) {
registry.put(link.getPageClass(), link);
}
});
}
public FilteredList<Region> getContent() {
return content;
}
public void setPredicate(Predicate<Region> predicate) {
content.setPredicate(predicate);
}
public Optional<NavLink> findLink(Class<? extends Page> pageClass) {
if (pageClass == null) { return Optional.empty(); }
return Optional.ofNullable(registry.get(pageClass));
}
public Optional<NavLink> getPrevious() {
var current = content.indexOf(registry.get(model.selectedPageProperty().get()));
if (!(current > 0)) { return Optional.empty(); }
for (int i = current - 1; i >= 0; i--) {
var r = content.get(i);
if (r instanceof NavLink link) { return Optional.of(link); }
}
return Optional.empty();
}
public Optional<NavLink> getNext() {
var current = content.indexOf(registry.get(model.selectedPageProperty().get()));
if (!(current >= 0 && current < content.size() - 1)) { return Optional.empty(); } // has next
for (int i = current + 1; i < content.size(); i++) {
var r = content.get(i);
if (r instanceof NavLink link) { return Optional.of(link); }
}
return Optional.empty();
}
private ObservableList<Region> create() {
return FXCollections.observableArrayList(
caption("GENERAL"),
navLink(ThemePage.NAME, ThemePage.class),
navLink(TypographyPage.NAME, TypographyPage.class),
new Separator(),
caption("COMPONENTS"),
navLink(OverviewPage.NAME, OverviewPage.class),
navLink(InputGroupPage.NAME, InputGroupPage.class),
@ -150,54 +210,44 @@ class Sidebar extends VBox {
caption("SHOWCASE"),
navLink(FileManagerPage.NAME, FileManagerPage.class),
navLink(MusicPlayerPage.NAME, MusicPlayerPage.class)
));
);
}
private Label caption(String text) {
var label = new Label(text);
label.getStyleClass().add("caption");
label.setMaxWidth(Double.MAX_VALUE);
return label;
}
private NavLink navLink(String text, Class<? extends Page> pageClass, String... keywords) {
return navLink(text, pageClass, false, keywords);
}
@SuppressWarnings("SameParameterValue")
private NavLink navLink(String text, Class<? extends Page> pageClass, boolean isNew, String... keywords) {
var link = new NavLink(text, pageClass, isNew);
var link = new NavLink(text, pageClass);
if (keywords != null && keywords.length > 0) {
link.getSearchKeywords().addAll(Arrays.asList(keywords));
}
link.setOnMouseClicked(e -> {
if (e.getSource() instanceof NavLink dest) { navigate(dest); }
if (e.getSource() instanceof NavLink target) {
model.navigate(target.getPageClass());
}
});
return link;
}
private void navigate(NavLink link) {
if (selectedLink.get() != null) { selectedLink.get().pseudoClassStateChanged(SELECTED, false); }
link.pseudoClassStateChanged(SELECTED, true);
selectedLink.set(link);
}
///////////////////////////////////////////////////////////////////////////
private static class NavLink extends Label {
private final Class<? extends Page> pageClass;
private final List<String> searchKeywords = new ArrayList<>();
public NavLink(String text, Class<? extends Page> pageClass, boolean isNew) {
public NavLink(String text, Class<? extends Page> pageClass) {
super(Objects.requireNonNull(text));
this.pageClass = Objects.requireNonNull(pageClass);
getStyleClass().add("nav-link");
setMaxWidth(Double.MAX_VALUE);
setContentDisplay(ContentDisplay.RIGHT);
if (isNew) {
var tag = new Label("new");
tag.getStyleClass().addAll("tag", Styles.TEXT_SMALL);
setGraphic(tag);
}
}
public Class<? extends Page> getPageClass() {

@ -1,55 +1,32 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page;
import atlantafx.base.controls.Popover;
import atlantafx.base.controls.Spacer;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.layout.Overlay;
import atlantafx.sampler.theme.ThemeManager;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import net.datafaker.Faker;
import org.kordamp.ikonli.Ikon;
import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2OutlinedMZ;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static atlantafx.base.controls.Popover.ArrowLocation.TOP_CENTER;
import static atlantafx.base.theme.Styles.*;
import static atlantafx.sampler.Launcher.IS_DEV_MODE;
import static atlantafx.sampler.util.Containers.setScrollConstraints;
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED;
public abstract class AbstractPage extends BorderPane implements Page {
protected static final int HEADER_HEIGHT = 50;
protected static final Faker FAKER = new Faker();
protected static final Random RANDOM = new Random();
protected static final EventHandler<ActionEvent> PRINT_SOURCE = System.out::println;
private static final Ikon ICON_CODE = Feather.CODE;
private static final Ikon ICON_SAMPLE = Feather.LAYOUT;
protected Button quickConfigBtn;
protected Popover quickConfigPopover;
protected Button sourceCodeToggleBtn;
protected StackPane codeViewerWrapper;
protected CodeViewer codeViewer;
protected VBox userContent;
protected Overlay overlay;
protected boolean isRendered = false;
@ -59,50 +36,9 @@ public abstract class AbstractPage extends BorderPane implements Page {
getStyleClass().add("page");
createPageLayout();
// update code view color theme on app theme change
DefaultEventBus.getInstance().subscribe(ThemeEvent.class, e -> {
if (ThemeManager.getInstance().getTheme() != null && isCodeViewActive()) {
loadSourceCodeAndMoveToFront();
}
});
}
protected void createPageLayout() {
// == header ==
var titleLabel = new Label(getName());
titleLabel.getStyleClass().addAll(Styles.TITLE_4);
codeViewer = new CodeViewer();
codeViewerWrapper = new StackPane();
codeViewerWrapper.getStyleClass().add("wrapper");
codeViewerWrapper.getChildren().setAll(codeViewer);
quickConfigBtn = new Button("", new FontIcon(Material2OutlinedMZ.STYLE));
quickConfigBtn.getStyleClass().addAll(BUTTON_ICON, FLAT);
quickConfigBtn.setTooltip(new Tooltip("Change theme"));
quickConfigBtn.setOnAction(e -> showThemeConfigPopover());
sourceCodeToggleBtn = new Button("", new FontIcon(ICON_CODE));
sourceCodeToggleBtn.getStyleClass().addAll(BUTTON_ICON, FLAT);
sourceCodeToggleBtn.setTooltip(new Tooltip("Source code"));
sourceCodeToggleBtn.setOnAction(e -> toggleSourceCode());
var header = new HBox(30);
header.getStyleClass().add("header");
header.setMinHeight(HEADER_HEIGHT);
header.setAlignment(Pos.CENTER_LEFT);
header.getChildren().setAll(titleLabel, new Spacer(), quickConfigBtn, sourceCodeToggleBtn);
if (IS_DEV_MODE) {
var devModeLabel = new Label("App is running in development mode");
devModeLabel.getStyleClass().addAll(TEXT_SMALL, "dev-mode-indicator");
header.getChildren().add(1, devModeLabel);
}
// == user content ==
userContent = new VBox();
userContent.getStyleClass().add("user-content");
@ -111,20 +47,10 @@ public abstract class AbstractPage extends BorderPane implements Page {
userContentWrapper.getChildren().setAll(userContent);
var scrollPane = new ScrollPane(userContentWrapper);
setScrollConstraints(scrollPane,
ScrollPane.ScrollBarPolicy.AS_NEEDED, true,
ScrollPane.ScrollBarPolicy.AS_NEEDED, true
);
setScrollConstraints(scrollPane, AS_NEEDED, true, AS_NEEDED, true);
scrollPane.setMaxHeight(10_000);
// == layout ==
var stackPane = new StackPane();
stackPane.getStyleClass().add("stack");
stackPane.getChildren().addAll(codeViewerWrapper, scrollPane);
setTop(header);
setCenter(stackPane);
setCenter(scrollPane);
}
@Override
@ -132,6 +58,16 @@ public abstract class AbstractPage extends BorderPane implements Page {
return this;
}
@Override
public boolean canDisplaySourceCode() {
return true;
}
@Override
public boolean canChangeThemeSettings() {
return true;
}
@Override
public void reset() { }
@ -150,65 +86,9 @@ public abstract class AbstractPage extends BorderPane implements Page {
}
protected Overlay lookupOverlay() {
return getScene() != null && getScene().lookup("." + Overlay.STYLE_CLASS) instanceof Overlay overlay ?
overlay : null;
return getScene() != null && getScene().lookup("." + Overlay.STYLE_CLASS) instanceof Overlay overlay ? overlay : null;
}
protected void showThemeConfigPopover() {
if (quickConfigPopover == null) {
var content = new QuickConfigMenu();
content.setExitHandler(() -> quickConfigPopover.hide());
quickConfigPopover = new Popover(content);
quickConfigPopover.setHeaderAlwaysVisible(false);
quickConfigPopover.setDetachable(false);
quickConfigPopover.setArrowLocation(TOP_CENTER);
quickConfigPopover.setOnShowing(e -> content.update());
}
quickConfigPopover.show(quickConfigBtn);
}
protected void toggleSourceCode() {
if (isCodeViewActive()) {
codeViewerWrapper.toBack();
((FontIcon) sourceCodeToggleBtn.getGraphic()).setIconCode(ICON_CODE);
return;
}
loadSourceCodeAndMoveToFront();
}
protected void loadSourceCodeAndMoveToFront() {
var sourceFileName = getClass().getSimpleName() + ".java";
try (var stream = getClass().getResourceAsStream(sourceFileName)) {
Objects.requireNonNull(stream, "Missing source file '" + sourceFileName + "';");
// set syntax highlight theme according to JavaFX theme
ThemeManager tm = ThemeManager.getInstance();
codeViewer.setContent(stream, tm.getMatchingSourceCodeHighlightTheme(tm.getTheme()));
var graphic = (FontIcon) sourceCodeToggleBtn.getGraphic();
graphic.setIconCode(ICON_SAMPLE);
codeViewerWrapper.toFront();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
protected final boolean isSampleViewActive() {
var graphic = (FontIcon) sourceCodeToggleBtn.getGraphic();
return graphic.getIconCode() == ICON_CODE;
}
protected final boolean isCodeViewActive() {
var graphic = (FontIcon) sourceCodeToggleBtn.getGraphic();
return graphic.getIconCode() == ICON_SAMPLE;
}
///////////////////////////////////////////////////////////////////////////
// Helpers //
///////////////////////////////////////////////////////////////////////////
protected <T> List<T> generate(Supplier<T> supplier, int count) {

@ -9,5 +9,9 @@ public interface Page {
Parent getView();
boolean canDisplaySourceCode();
boolean canChangeThemeSettings();
void reset();
}

@ -7,7 +7,6 @@ import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.theme.SamplerTheme;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.util.NodeUtils;
import javafx.geometry.HPos;
import javafx.scene.control.Button;
import javafx.scene.control.ChoiceBox;
@ -53,7 +52,19 @@ public class ThemePage extends AbstractPage {
private ContrastCheckerDialog contrastCheckerDialog;
@Override
public String getName() { return NAME; }
public String getName() {
return NAME;
}
@Override
public boolean canDisplaySourceCode() {
return false;
}
@Override
public boolean canChangeThemeSettings() {
return false;
}
public ThemePage() {
super();
@ -94,11 +105,6 @@ public class ThemePage extends AbstractPage {
);
selectCurrentTheme();
// if you want to enable quick menu don't forget that
// theme selection choice box have to be updated accordingly
NodeUtils.toggleVisibility(quickConfigBtn, false);
NodeUtils.toggleVisibility(sourceCodeToggleBtn, false);
}
private GridPane optionsGrid() {

@ -38,7 +38,19 @@ public class TypographyPage extends AbstractPage {
public static final String NAME = "Typography";
@Override
public String getName() { return NAME; }
public String getName() {
return NAME;
}
@Override
public boolean canDisplaySourceCode() {
return false;
}
@Override
public boolean canChangeThemeSettings() {
return false;
}
private Pane fontSizeSampleContent;
@ -74,10 +86,6 @@ public class TypographyPage extends AbstractPage {
textColorSample().getRoot(),
textFlowSample().getRoot()
);
// if you want to enable quick menu don't forget that
// font size spinner value have to be updated accordingly
NodeUtils.toggleVisibility(quickConfigBtn, false);
NodeUtils.toggleVisibility(sourceCodeToggleBtn, false);
}
private ComboBox<String> fontFamilyChooser() {

@ -29,6 +29,11 @@ public abstract class ShowcasePage extends AbstractPage {
createShowcaseLayout();
}
@Override
public boolean canDisplaySourceCode() {
return false;
}
protected void createShowcaseLayout() {
var expandBtn = new Button("Expand");
expandBtn.setGraphic(new FontIcon(Feather.MAXIMIZE_2));
@ -59,9 +64,6 @@ public abstract class ShowcasePage extends AbstractPage {
collapseBox.setVisible(false);
collapseBox.setManaged(false);
sourceCodeToggleBtn.setVisible(false);
sourceCodeToggleBtn.setManaged(false);
userContent.getChildren().setAll(showcase, expandBox, collapseBox);
}

@ -1,4 +1,3 @@
app.name=AtlantaFX Sampler
app.description=${project.description}
app.homepage=${project.url}
app.homepage=${project.parent.url}
app.version=${project.version}

@ -22,11 +22,8 @@
}
.page {
>.header {
@include hide();
}
>.stack>.scroll-pane>.viewport>*>.wrapper>.user-content {
>.scroll-pane>.viewport>*>.wrapper>.user-content {
-fx-max-width: 4096px;
-fx-padding: 0;
-fx-spacing: 0;

@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
@use "sidebar";
@use "main";
@use "overlay";
@use "page";
@use "components";
@use "overlay";

@ -0,0 +1,125 @@
@mixin link-button() {
-fx-icon-color: -color-fg-emphasis;
-fx-fill: -color-fg-emphasis;
-fx-icon-size: 20px;
&:hover {
-fx-opacity: 0.75;
}
}
#main {
.source-code {
-fx-background-color: -color-bg-default;
-fx-alignment: TOP_CENTER;
>.code-viewer {
-fx-background-color: transparent;
-fx-padding: 0 0 0 20px;
-fx-max-width: 1000px;
.web-view {
-fx-padding: 0;
-fx-background-color: transparent;
}
}
}
}
#header-bar {
-fx-background-color: -color-accent-emphasis;
-fx-padding: 0px 30px 0px 0;
-fx-spacing: 30px;
>.logo {
-fx-padding: 0px 0 0px 20px;
>.label {
-fx-text-fill: -color-fg-emphasis;
}
>.version {
-fx-padding: -1em 0 0 0;
}
>.ikonli-font-icon {
@include link-button();
}
}
>.text-field {
-fx-background-insets: 0;
-fx-padding: 0;
-fx-opacity: 0.85;
-color-input-bg: -color-accent-emphasis;
-color-input-bg-focused: -color-accent-emphasis;
-color-input-fg: -color-fg-emphasis;
-color-input-border: -color-fg-emphasis;
-color-input-border-focused: -color-fg-emphasis;
-fx-prompt-text-fill: -color-fg-emphasis;
.ikonli-font-icon {
-fx-icon-color: -color-fg-emphasis;
-fx-fill: -color-fg-emphasis;
}
}
>.page-title {
-fx-text-fill: -color-fg-emphasis;
-fx-padding: 0 0 0 30px;
}
>.ikonli-font-icon {
@include link-button();
&:disabled {
-fx-opacity: 0.1;
}
}
>.dev-mode-indicator {
-fx-background-color: -color-warning-emphasis;
-fx-text-fill: -color-fg-emphasis;
-fx-background-radius: 10px;
-fx-padding: 5px 10px 5px 10px;
}
}
#sidebar {
-fx-border-color: -color-border-default;
-fx-border-width: 0 1px 0 0;
>.scroll-pane {
-fx-background-color: -color-bg-inset;
-fx-padding: 0 16px 10px 16px;
}
&:filtered {
>.scroll-pane {
-fx-padding: 10px 16px 10px 16px;
}
}
.nav-menu {
>.caption {
-fx-padding: 18px 0 10px 0;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-muted;
}
>.nav-link {
-fx-padding: 6px 8px 6px 8px;
&:hover {
-fx-background-color: -color-accent-muted;
-fx-background-radius: 6px;
}
&:selected {
-fx-text-fill: -color-accent-fg;
-fx-font-weight: bold;
}
}
}
}

@ -1,27 +1,10 @@
// SPDX-License-Identifier: MIT
.page {
>.header {
-fx-padding: 10px 20px 14px 20px;
-fx-spacing: 10px;
-fx-background-color: -color-bg-default;
>.dev-mode-indicator {
-fx-background-color: -color-warning-subtle;
-fx-background-radius: 10px;
-fx-text-fill: -color-warning-fg;
-fx-padding: 5px;
-fx-border-color: -color-warning-muted;
-fx-border-width: 1px;
-fx-border-radius: 10px;
}
}
>.stack {
>.scroll-pane {
-fx-background-color: -color-bg-default;
/* wrapper is used to center the content and also guarantees some minimum paddings via min-width */
>.viewport>*>.wrapper {
-fx-min-width: 880px;
-fx-alignment: TOP_CENTER;
@ -33,20 +16,4 @@
}
}
}
>.wrapper {
-fx-background-color: -color-bg-default;
-fx-alignment: TOP_CENTER;
>.code-viewer {
-fx-background-color: transparent;
-fx-padding: 0px 0px 20px 20px;
-fx-max-width: 1000px;
>.web-view {
-fx-background-color: transparent;
}
}
}
}
}

@ -1,45 +0,0 @@
// SPDX-License-Identifier: MIT
#sidebar {
-fx-padding: 0 0 12px 0;
-fx-background-color: -color-bg-inset;
-fx-border-color: -color-border-default;
-fx-border-width: 0 1px 0 0;
#search-form {
-fx-padding: 12px;
-fx-border-color: -color-border-muted;
-fx-border-width: 0 0 1px 0;
}
.nav-menu {
-fx-padding: 0 6px 0 6px;
>.caption {
-fx-padding: 10px 0 10px 6px;
-fx-font-weight: bold;
-fx-text-fill: -color-fg-muted;
}
>.nav-link {
-fx-padding: 6px 12px 6px 12px;
>.tag {
-fx-background-color: -color-accent-emphasis;
-fx-text-fill: -color-fg-emphasis;
-fx-padding: 2px;
-fx-background-radius: 4px;
}
&:hover {
-fx-background-color: -color-accent-muted;
-fx-background-radius: 6px;
}
&:selected {
-fx-text-fill: -color-accent-fg;
-fx-font-weight: bold;
}
}
}
}