Add external themes support

This commit is contained in:
mkpaz 2022-09-14 21:26:58 +04:00
parent a65ea977c0
commit 9fe8cbde8a
25 changed files with 893 additions and 224 deletions

@ -2,9 +2,17 @@
## [Unreleased]
### Features
- (Sampler) External themes support. Sampler can now be used to develop custom themes. Just clone [AtlantaFX sample theme](https://github.com/mkpaz/atlantafx-sample-theme) repository, compile it and add resulting CSS file to the Sampler.
- (CSS) `TextField` and `Button` now support rounded style.
### Improvements
- `Hyperlink`, `TextField`, `TextArea` and `ProgressBar` can now be styled via looked-up color variables.
- (CSS) Nord light and dark themes revamped with better color contrast and improved design.
- (CSS) `Hyperlink`, `TextField`, `TextArea` and `ProgressBar` can now be styled via looked-up color variables.
### Fixes
## [0.1]

@ -80,7 +80,7 @@ public class InlineDatePicker extends Control {
if (isValidDate(chrono, date)) {
lastValidDate = date;
} else {
System.err.println("Restoring value to " + (lastValidDate == null ? "null" : lastValidDate));
System.err.println("[ERROR] Restoring value to " + (lastValidDate == null ? "null" : lastValidDate));
setValue(lastValidDate);
}
});
@ -92,7 +92,7 @@ public class InlineDatePicker extends Control {
if (isValidDate(chrono, date)) {
lastValidChronology = chrono;
} else {
System.err.println("Restoring value to " + lastValidChronology);
System.err.println("[ERROR] Restoring value to " + lastValidChronology);
setChronology(lastValidChronology);
}
});

@ -1,40 +0,0 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
public abstract class AbstractTheme implements Theme {
private final Set<URI> stylesheets;
public AbstractTheme() {
this(new LinkedHashSet<>());
}
public AbstractTheme(URI... stylesheets) {
this(Set.of(stylesheets));
}
public AbstractTheme(Set<URI> stylesheets) {
this.stylesheets = Objects.requireNonNull(stylesheets);
}
@Override
public Set<URI> getStylesheets() {
return stylesheets;
}
@Override
public String toString() {
return getClass().getSimpleName() +
"{" +
"name=" + getName() +
", userAgentStylesheet=" + getUserAgentStylesheet() +
", stylesheets=" + stylesheets +
", isDarkMode=" + isDarkMode() +
'}';
}
}

@ -1,20 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.Set;
public final class NordDark implements Theme {
public class NordDark extends AbstractTheme {
public NordDark() {}
public NordDark(URI... stylesheets) {
super(stylesheets);
}
public NordDark(Set<URI> stylesheets) {
super(stylesheets);
}
public NordDark() { }
@Override
public String getName() {

@ -1,20 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.Set;
public final class NordLight implements Theme {
public class NordLight extends AbstractTheme {
public NordLight() {}
public NordLight(URI... stylesheets) {
super(stylesheets);
}
public NordLight(Set<URI> stylesheets) {
super(stylesheets);
}
public NordLight() { }
@Override
public String getName() {

@ -1,20 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.Set;
public final class PrimerDark implements Theme {
public class PrimerDark extends AbstractTheme {
public PrimerDark() {}
public PrimerDark(URI... stylesheets) {
super(stylesheets);
}
public PrimerDark(Set<URI> stylesheets) {
super(stylesheets);
}
public PrimerDark() { }
@Override
public String getName() {

@ -1,20 +1,9 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.Set;
public final class PrimerLight implements Theme {
public class PrimerLight extends AbstractTheme {
public PrimerLight() {}
public PrimerLight(URI... stylesheets) {
super(stylesheets);
}
public PrimerLight(Set<URI> stylesheets) {
super(stylesheets);
}
public PrimerLight() { }
@Override
public String getName() {

@ -1,8 +1,7 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.theme;
import java.net.URI;
import java.util.Set;
import java.util.Objects;
import static javafx.application.Application.STYLESHEET_CASPIAN;
import static javafx.application.Application.STYLESHEET_MODENA;
@ -16,10 +15,31 @@ public interface Theme {
String getUserAgentStylesheet();
Set<URI> getStylesheets();
boolean isDarkMode();
static Theme of(String name, String userAgentStylesheet, boolean darkMode) {
Objects.requireNonNull(name);
Objects.requireNonNull(userAgentStylesheet);
return new Theme() {
@Override
public String getName() {
return name;
}
@Override
public String getUserAgentStylesheet() {
return userAgentStylesheet;
}
@Override
public boolean isDarkMode() {
return darkMode;
}
};
}
default boolean isDefault() {
return STYLESHEET_MODENA.equals(getUserAgentStylesheet()) || STYLESHEET_CASPIAN.equals(getUserAgentStylesheet());
}

@ -223,7 +223,7 @@
</goals>
<configuration>
<toolName>jlink</toolName>
<addModules>java.base,java.logging,jdk.localedata,java.desktop,javafx.controls,javafx.swing,javafx.web</addModules>
<addModules>java.base,java.logging,jdk.localedata,java.desktop,java.prefs,javafx.controls,javafx.swing,javafx.web</addModules>
<modulePath>${build.platformModulesDir}</modulePath>
<output>${build.package.runtimeImageDir}</output>
<args>

@ -0,0 +1,72 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
public final class FileResource {
private final String location;
private final boolean internal;
private final Class<?> anchor;
private FileResource(String location, boolean internal, Class<?> anchor) {
this.location = location;
this.internal = internal;
this.anchor = anchor;
}
public String location() {
return location;
}
public boolean internal() {
return internal;
}
public boolean exists() {
return internal ? anchor.getResource(location) != null : Files.exists(toPath());
}
public Path toPath() {
return Paths.get(location);
}
public URI toURI() {
// the latter adds "file://" scheme to the URI
return internal ? URI.create(location) : Paths.get(location).toUri();
}
public String getFilename() {
return Paths.get(location).getFileName().toString();
}
public InputStream getInputStream() throws IOException {
if (internal) {
var is = anchor.getResourceAsStream(location);
if (is == null) { throw new IOException("Resource not found: " + location); }
return is;
}
return new FileInputStream(toPath().toFile());
}
///////////////////////////////////////////////////////////////////////////
public static FileResource internal(String location) {
return internal(location, FileResource.class);
}
public static FileResource internal(String location, Class<?> anchor) {
return new FileResource(location, true, Objects.requireNonNull(anchor));
}
public static FileResource external(String location) {
return new FileResource(location, false, null);
}
}

@ -9,6 +9,7 @@ import atlantafx.sampler.theme.ThemeManager;
import fr.brouillard.oss.cssfx.CSSFX;
import fr.brouillard.oss.cssfx.api.URIToPathConverter;
import fr.brouillard.oss.cssfx.impl.log.CSSFXLogger;
import fr.brouillard.oss.cssfx.impl.log.CSSFXLogger.LogLevel;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
@ -55,7 +56,7 @@ public class Launcher extends Application {
var tm = ThemeManager.getInstance();
tm.setScene(scene);
tm.setTheme(tm.getAvailableThemes().get(0));
tm.setTheme(tm.getDefaultTheme());
if (IS_DEV_MODE) { startCssFX(scene); }
scene.getStylesheets().addAll(Resources.resolve("assets/styles/index.css"));
@ -110,9 +111,11 @@ public class Launcher extends Application {
};
CSSFX.addConverter(fileUrlConverter).start();
CSSFXLogger.setLoggerFactory(loggerName -> (level, message, args) ->
System.out.println("[CSSFX] " + String.format(message, args))
);
CSSFXLogger.setLoggerFactory(loggerName -> (level, message, args) -> {
if (level.ordinal() <= LogLevel.INFO.ordinal()) {
System.out.println("[" + level + "] CSSFX: " + String.format(message, args));
}
});
CSSFX.start(scene);
}

@ -5,6 +5,7 @@ import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.Objects;
import java.util.prefs.Preferences;
public final class Resources {
@ -32,4 +33,8 @@ public final class Resources {
public static String getPropertyOrEnv(String propertyKey, String envKey) {
return System.getProperty(propertyKey, System.getenv(envKey));
}
public static Preferences getPreferences() {
return Preferences.userRoot().node("atlantafx");
}
}

@ -18,6 +18,9 @@ public class ThemeEvent extends Event {
// font size or family only change
FONT_CHANGE,
// colors only change
COLOR_CHANGE
COLOR_CHANGE,
// new theme added or removed
THEME_ADD,
THEME_REMOVE
}
}

@ -211,7 +211,7 @@ public class QuickConfigMenu extends StackPane {
getChildren().setAll(mainMenu, new Separator());
tm.getAvailableThemes().forEach(theme -> {
tm.getRepository().getAll().forEach(theme -> {
var icon = new FontIcon(Material2AL.CHECK);
var item = new HBox(20, icon, new Label(theme.getName()));

@ -1,36 +1,42 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import atlantafx.base.theme.Theme;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.event.DefaultEventBus;
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;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2OutlinedMZ;
import java.net.URI;
import java.util.Objects;
import java.util.function.Consumer;
import static atlantafx.sampler.event.ThemeEvent.EventType.COLOR_CHANGE;
import static atlantafx.sampler.event.ThemeEvent.EventType.THEME_CHANGE;
import static atlantafx.sampler.event.ThemeEvent.EventType.*;
import static atlantafx.sampler.util.Controls.hyperlink;
public class ThemePage extends AbstractPage {
public static final String NAME = "Theme";
private static final ThemeManager TM = ThemeManager.getInstance();
private final Consumer<ColorPaletteBlock> colorBlockActionHandler = colorBlock -> {
ContrastCheckerDialog dialog = getOrCreateContrastCheckerDialog();
dialog.getContent().setValues(colorBlock.getFgColorName(),
dialog.getContent().setValues(
colorBlock.getFgColorName(),
colorBlock.getFgColor(),
colorBlock.getBgColorName(),
colorBlock.getBgColor()
@ -41,7 +47,9 @@ public class ThemePage extends AbstractPage {
private final ColorPalette colorPalette = new ColorPalette(colorBlockActionHandler);
private final ColorScale colorScale = new ColorScale();
private final ChoiceBox<SamplerTheme> themeSelector = themeSelector();
private ThemeRepoManagerDialog themeRepoManagerDialog;
private ContrastCheckerDialog contrastCheckerDialog;
@Override
@ -51,6 +59,10 @@ public class ThemePage extends AbstractPage {
super();
createView();
DefaultEventBus.getInstance().subscribe(ThemeEvent.class, e -> {
if (e.getEventType() == THEME_ADD || e.getEventType() == THEME_REMOVE) {
themeSelector.getItems().setAll(TM.getRepository().getAll());
selectCurrentTheme();
}
if (e.getEventType() == THEME_CHANGE || e.getEventType() == COLOR_CHANGE) {
colorPalette.updateColorInfo(Duration.seconds(1));
colorScale.updateColorInfo(Duration.seconds(1));
@ -69,7 +81,7 @@ public class ThemePage extends AbstractPage {
var noteText = new TextFlow(
new Text("AtlantaFX follows "),
hyperlink("Github Primer interface guidelines",
URI.create("https://primer.style/design/foundations/color")
URI.create("https://primer.style/design/foundations/color")
),
new Text(" and color system.")
);
@ -81,6 +93,8 @@ public class ThemePage extends AbstractPage {
colorScale
);
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);
@ -88,8 +102,17 @@ public class ThemePage extends AbstractPage {
}
private GridPane optionsGrid() {
ChoiceBox<Theme> themeSelector = themeSelector();
themeSelector.setPrefWidth(200);
var themeRepoBtn = new Button("", new FontIcon(Material2OutlinedMZ.SETTINGS));
themeRepoBtn.getStyleClass().addAll(Styles.BUTTON_ICON, Styles.FLAT);
themeRepoBtn.setTooltip(new Tooltip("Settings"));
themeRepoBtn.setOnAction(e -> {
ThemeRepoManagerDialog dialog = getOrCreateThemeRepoManagerDialog();
overlay.setContent(dialog, HPos.CENTER);
dialog.getContent().update();
overlay.toFront();
});
var accentSelector = new AccentColorSelector();
@ -101,47 +124,58 @@ public class ThemePage extends AbstractPage {
grid.add(new Label("Color theme"), 0, 0);
grid.add(themeSelector, 1, 0);
grid.add(themeRepoBtn, 2, 0);
grid.add(new Label("Accent color"), 0, 1);
grid.add(accentSelector, 1, 1);
return grid;
}
private ChoiceBox<Theme> themeSelector() {
var manager = ThemeManager.getInstance();
var selector = new ChoiceBox<Theme>();
selector.getItems().setAll(manager.getAvailableThemes());
private ChoiceBox<SamplerTheme> themeSelector() {
var selector = new ChoiceBox<SamplerTheme>();
selector.getItems().setAll(TM.getRepository().getAll());
selector.getSelectionModel().selectedItemProperty().addListener((obs, old, val) -> {
if (val != null && getScene() != null) {
var tm = ThemeManager.getInstance();
tm.setTheme(val);
}
if (val != null && getScene() != null) { TM.setTheme(val); }
});
selector.setPrefWidth(250);
selector.setConverter(new StringConverter<>() {
@Override
public String toString(Theme theme) {
return theme.getName();
public String toString(SamplerTheme theme) {
return theme != null ? theme.getName() : "";
}
@Override
public Theme fromString(String themeName) {
return manager.getAvailableThemes().stream()
public SamplerTheme fromString(String themeName) {
return TM.getRepository().getAll().stream()
.filter(t -> Objects.equals(themeName, t.getName()))
.findFirst()
.orElse(null);
}
});
// select current theme
if (manager.getTheme() != null) {
selector.getItems().stream()
.filter(t -> Objects.equals(manager.getTheme().getName(), t.getName()))
.findFirst()
.ifPresent(t -> selector.getSelectionModel().select(t));
return selector;
}
private void selectCurrentTheme() {
if (TM.getTheme() == null) { return; }
themeSelector.getItems().stream()
.filter(t -> Objects.equals(TM.getTheme().getName(), t.getName()))
.findFirst()
.ifPresent(t -> themeSelector.getSelectionModel().select(t));
}
private ThemeRepoManagerDialog getOrCreateThemeRepoManagerDialog() {
if (themeRepoManagerDialog == null) {
themeRepoManagerDialog = new ThemeRepoManagerDialog();
}
return selector;
themeRepoManagerDialog.setOnCloseRequest(() -> {
overlay.removeContent();
overlay.toBack();
});
return themeRepoManagerDialog;
}
private ContrastCheckerDialog getOrCreateContrastCheckerDialog() {

@ -0,0 +1,227 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import atlantafx.base.controls.Spacer;
import atlantafx.sampler.theme.SamplerTheme;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.theme.ThemeRepository;
import atlantafx.sampler.util.Containers;
import javafx.concurrent.Task;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2OutlinedAL;
import java.io.File;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import static atlantafx.base.theme.Styles.*;
import static javafx.scene.control.ScrollPane.ScrollBarPolicy.AS_NEEDED;
import static javafx.scene.layout.Priority.ALWAYS;
class ThemeRepoManager extends VBox {
private static final Executor THREAD_POOL = Executors.newFixedThreadPool(3);
private static final ThemeRepository REPO = ThemeManager.getInstance().getRepository();
private VBox themeList;
private final Consumer<SamplerTheme> deleteHandler = theme -> {
REPO.remove(theme);
themeList.getChildren().removeIf(c -> {
if (c instanceof ThemeCell cell) {
return Objects.equals(theme.getName(), cell.getTheme().getName());
}
return false;
});
};
public ThemeRepoManager() {
super();
createView();
}
private void createView() {
themeList = new VBox();
themeList.getStyleClass().add("theme-list");
REPO.getAll().forEach(theme -> themeList.getChildren().add(themeCell(theme)));
var scrollPane = new ScrollPane(themeList);
Containers.setScrollConstraints(scrollPane, AS_NEEDED, true, AS_NEEDED, true);
scrollPane.setMaxHeight(4000);
VBox.setVgrow(scrollPane, ALWAYS);
var infoBox = new HBox(new Label("Default or selected themes can not be removed."));
infoBox.getStyleClass().add("info");
setId("theme-repo-manager");
getChildren().addAll(scrollPane, infoBox);
}
public void update() {
themeList.getChildren().forEach(c -> {
if (c instanceof ThemeCell cell) { cell.update(); }
});
}
public void addFromFile(File file) {
SamplerTheme theme = REPO.addFromFile(file);
themeList.getChildren().add(themeCell(theme));
}
private ThemeCell themeCell(SamplerTheme theme) {
var cell = new ThemeCell(theme);
cell.setOnDelete(deleteHandler);
return cell;
}
///////////////////////////////////////////////////////////////////////////
private static class ThemeCell extends HBox {
private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected");
private final SamplerTheme theme;
private Button deleteBtn;
private Consumer<SamplerTheme> deleteHandler;
public ThemeCell(SamplerTheme theme) {
this.theme = theme;
createView();
}
private void createView() {
setAlignment(Pos.CENTER_LEFT);
getStyleClass().add("theme");
// == TITLE ==
var text = new Text(theme.getName());
text.getStyleClass().addAll("text");
String path = theme.getPath();
if (path.length() > 64) {
path = "..." + path.substring(path.length() - 64);
}
var subText = new Text(path);
subText.getStyleClass().addAll(TEXT_SMALL, "sub-text");
var titleBox = new VBox(5);
titleBox.getStyleClass().addAll("title");
titleBox.getChildren().setAll(text, subText);
getChildren().addAll(titleBox, new Spacer(), new Region(/* placeholder */));
// == PREVIEW ==
var task = new Task<Map<String, String>>() {
@Override
protected Map<String, String> call() throws Exception {
return theme.parseColors();
}
};
task.setOnSucceeded(e -> {
var colors = task.getValue();
if (colors != null && !colors.isEmpty()) {
var style = new StringBuilder();
var presence = 0;
presence += appendStyleIfPresent(colors, style, "-color-bg-default");
presence += appendStyleIfPresent(colors, style, "-color-fg-default");
presence += appendStyleIfPresent(colors, style, "-color-fg-emphasis");
presence += appendStyleIfPresent(colors, style, "-color-accent-emphasis");
presence += appendStyleIfPresent(colors, style, "-color-success-emphasis");
presence += appendStyleIfPresent(colors, style, "-color-danger-emphasis");
// all colors must be present for preview
if (presence == 6) {
var previewBox = new HBox();
previewBox.setAlignment(Pos.CENTER_LEFT);
previewBox.getStyleClass().add("preview");
previewBox.setStyle(style.toString());
previewBox.getChildren().setAll(
previewLabel("A", "-color-bg-default", "-color-fg-default"),
previewLabel("B", "-color-accent-emphasis", "-color-fg-emphasis"),
previewLabel("C", "-color-success-emphasis", "-color-fg-emphasis"),
previewLabel("D", "-color-danger-emphasis", "-color-fg-emphasis")
);
getChildren().set(2, previewBox);
}
}
});
task.setOnFailed(
e -> System.err.println("[ERROR] Unable to parse \"" + theme.getName() + "\" theme colors. Either CSS not valid or file isn't readable.")
);
THREAD_POOL.execute(task);
// == CONTROLS ==
deleteBtn = new Button("", new FontIcon(Material2OutlinedAL.DELETE));
deleteBtn.getStyleClass().addAll(BUTTON_ICON, BUTTON_CIRCLE, FLAT, DANGER);
deleteBtn.setOnAction(e -> {
if (deleteHandler != null) { deleteHandler.accept(theme); }
});
var controlsBox = new HBox();
controlsBox.getStyleClass().add("controls");
controlsBox.setAlignment(Pos.CENTER_RIGHT);
controlsBox.getChildren().add(deleteBtn);
getChildren().add(3, controlsBox);
}
public SamplerTheme getTheme() {
return theme;
}
public void update() {
boolean isSelectedTheme = isSelectedTheme();
pseudoClassStateChanged(SELECTED, isSelectedTheme);
deleteBtn.setDisable(theme.isProjectTheme() || isSelectedTheme);
}
public void setOnDelete(Consumer<SamplerTheme> handler) {
this.deleteHandler = handler;
}
private boolean isSelectedTheme() {
return Objects.equals(ThemeManager.getInstance().getTheme().getName(), theme.getName());
}
private int appendStyleIfPresent(Map<String, String> colors, StringBuilder sb, String colorName) {
var value = colors.get(colorName);
if (value != null) {
sb.append(colorName);
sb.append(":");
sb.append(value);
sb.append(";");
return 1;
}
return 0;
}
private Label previewLabel(String text, String bg, String fg) {
var label = new Label(text);
label.setStyle(String.format("-fx-background-color:%s;-fx-text-fill:%s;", bg, fg));
return label;
}
}
}

@ -0,0 +1,40 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import atlantafx.base.theme.Styles;
import atlantafx.sampler.page.OverlayDialog;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.stage.FileChooser;
import javafx.stage.FileChooser.ExtensionFilter;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2MZ;
import java.io.File;
class ThemeRepoManagerDialog extends OverlayDialog<ThemeRepoManager> {
private final ThemeRepoManager repoManager = new ThemeRepoManager();
public ThemeRepoManagerDialog() {
setId("theme-repo-manager-dialog");
setTitle("Theme Manager");
setContent(repoManager);
var addBtn = new Button("Add", new FontIcon(Material2MZ.PLUS));
addBtn.getStyleClass().add(Styles.ACCENT);
addBtn.setOnAction(e -> {
var fileChooser = new FileChooser();
fileChooser.getExtensionFilters().addAll(new ExtensionFilter("CSS (*.css)", "*.css"));
File file = fileChooser.showOpenDialog(getScene().getWindow());
if (file != null) { repoManager.addFromFile(file); }
});
footerBox.getChildren().add(0, addBtn);
footerBox.setAlignment(Pos.CENTER_LEFT);
}
public ThemeRepoManager getContent() {
return repoManager;
}
}

@ -1,38 +0,0 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.theme;
import atlantafx.base.theme.AbstractTheme;
import java.net.URI;
import java.util.Objects;
import java.util.Set;
public class ExternalTheme extends AbstractTheme {
private final String name;
private final String stylesheet;
private final boolean darkMode;
public ExternalTheme(String name, String stylesheet, Set<URI> stylesheets, boolean darkMode) {
super(stylesheets);
this.name = Objects.requireNonNull(name);
this.stylesheet = Objects.requireNonNull(stylesheet);
this.darkMode = darkMode;
}
@Override
public String getName() {
return name;
}
@Override
public String getUserAgentStylesheet() {
return stylesheet;
}
@Override
public boolean isDarkMode() {
return darkMode;
}
}

@ -0,0 +1,189 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.theme;
import atlantafx.base.theme.Theme;
import atlantafx.sampler.FileResource;
import atlantafx.sampler.Launcher;
import atlantafx.sampler.Resources;
import fr.brouillard.oss.cssfx.CSSFX;
import javafx.application.Application;
import javafx.scene.Scene;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static atlantafx.sampler.Launcher.IS_DEV_MODE;
import static atlantafx.sampler.Resources.resolve;
import static atlantafx.sampler.theme.ThemeManager.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
/**
* The {@link Theme} decorator to work around some JavaFX CSS limitations.
* <br/><br/>
* JavaFX doesn't provide the theme API. Moreover, there is no such notion, it's called
* "user agent stylesheet". There are two ways to change the platform stylesheet:
* <ul>
* <li>{@link Scene#setUserAgentStylesheet(String)}</li>
* <li>{@link Application#setUserAgentStylesheet(String)}</li>
* </ul>
* The first one is observable and the latter is not. Both fail to provide hot reload with
* {@link CSSFX}. This means that if we'd set up some CSS file as the platform stylesheet
* and then change (re-compile) this file, nothing will happen.
* <br/><br/>
* To work around this issue we use dev mode trick. In this mode, we set the dummy stylesheet
* to the Application, while real theme CSS is added to the {@link Scene#getStylesheets()}
* along with app own stylesheet. When CSSFX detects any of those CSS files changes, it forces
* observable list update and thus reloading CSS with changes.
* <br/><br/>
* Also, note that some styles aren't applied when using {@link Application#setUserAgentStylesheet(String)}.
* E.g. JavaFX ignores Ikonli -fx-icon-color and -fx-icon-size properties, but for an unknown
* reason they won't be ignored when exactly the same stylesheet is set via {@link Scene#getStylesheets()}.
* </p>
*/
public final class SamplerTheme implements Theme {
private static final int PARSE_LIMIT = 250;
private static final Pattern COLOR_PATTERN =
Pattern.compile("\s*?(-color-(fg|bg|accent|success|danger)-.+?):\s*?(.+?);");
private final Theme theme;
private FileTime lastModified;
private Map<String, String> colors;
public SamplerTheme(Theme theme) {
Objects.requireNonNull(theme);
if (theme instanceof SamplerTheme) {
throw new IllegalArgumentException("Sampler theme must not be wrapped into itself.");
}
this.theme = theme;
}
@Override
public String getName() {
return theme.getName();
}
// Application.setUserAgentStylesheet() only accepts URL (or URL string representation),
// any external file path must have "file://" prefix
@Override
public String getUserAgentStylesheet() {
return IS_DEV_MODE ? DUMMY_STYLESHEET : getThemeFile().toURI().toString();
}
@Override
public boolean isDarkMode() {
return theme.isDarkMode();
}
public Set<String> getAllStylesheets() {
return IS_DEV_MODE ? merge(getThemeFile().toURI().toString(), APP_STYLESHEETS) : Set.of(APP_STYLESHEETS);
}
// Checks whether wrapped theme is a project theme or user external theme.
public boolean isProjectTheme() {
return PROJECT_THEMES.contains(theme.getClass());
}
// Tries to parse theme CSS and extract conventional looked-up colors. There are few limitations:
// - minified CSS files are not supported
// - only first PARSE_LIMIT lines will be read
public Map<String, String> parseColors() throws IOException {
FileResource file = getThemeFile();
return file.internal() ? parseColorsForClasspath(file) : parseColorsForFilesystem(file);
}
private Map<String, String> parseColorsForClasspath(FileResource file) throws IOException {
// classpath resources are static, no need to parse project theme more than once
if (colors != null) { return colors; }
try (var br = new BufferedReader(new InputStreamReader(file.getInputStream()))) {
colors = parseColors(br);
}
return colors;
}
private Map<String, String> parseColorsForFilesystem(FileResource file) throws IOException {
// return cached colors if file wasn't changed since the last read
FileTime fileTime = Files.getLastModifiedTime(file.toPath(), NOFOLLOW_LINKS);
if (Objects.equals(fileTime, lastModified)) {
return colors;
}
try (var br = new BufferedReader(new InputStreamReader(file.getInputStream(), UTF_8))) {
colors = parseColors(br);
}
// don't save time before parsing is finished to avoid
// remembering operation that might end up with an error
lastModified = fileTime;
return colors;
}
private Map<String, String> parseColors(BufferedReader br) throws IOException {
Map<String, String> colors = new HashMap<>();
String line;
int lineCount = 0;
while ((line = br.readLine()) != null) {
Matcher matcher = COLOR_PATTERN.matcher(line);
if (matcher.matches()) {
colors.put(matcher.group(1), matcher.group(3));
}
lineCount++;
if (lineCount > PARSE_LIMIT) { break; }
}
return colors;
}
public String getPath() {
return getThemeFile().toPath().toString();
}
private FileResource getThemeFile() {
if (!isProjectTheme()) {
return FileResource.external(theme.getUserAgentStylesheet());
}
FileResource classpathTheme = FileResource.internal(theme.getUserAgentStylesheet(), Theme.class);
if (!IS_DEV_MODE) { return classpathTheme; }
String filename = classpathTheme.getFilename();
try {
FileResource testTheme = FileResource.internal(Resources.resolve("theme-test/" + filename), Launcher.class);
if (!testTheme.exists()) { throw new IOException(); }
return testTheme;
} catch (Exception e) {
var failedPath = resolve("theme-test/" + filename);
System.err.println("[WARNING] Unable to find theme file \"" + failedPath + "\". Fall back to the classpath.");
return classpathTheme;
}
}
public Theme unwrap() {
return theme;
}
@SafeVarargs
private <T> Set<T> merge(T first, T... arr) {
var set = new LinkedHashSet<T>();
set.add(first);
Collections.addAll(set, arr);
return set;
}
}

@ -2,7 +2,6 @@
package atlantafx.sampler.theme;
import atlantafx.base.theme.*;
import atlantafx.sampler.Launcher;
import atlantafx.sampler.Resources;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.EventBus;
@ -12,10 +11,8 @@ import atlantafx.sampler.util.JColor;
import javafx.application.Application;
import javafx.css.PseudoClass;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import java.net.URI;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@ -25,7 +22,14 @@ import static java.nio.charset.StandardCharsets.UTF_8;
public final class ThemeManager {
private static final String DUMMY_STYLESHEET = getResource("assets/styles/empty.css").toString();
static final String DUMMY_STYLESHEET = getResource("assets/styles/empty.css").toString();
static final String[] APP_STYLESHEETS = new String[] {
Resources.resolve("assets/styles/index.css")
};
static final Set<Class<? extends Theme>> PROJECT_THEMES = Set.of(
PrimerLight.class, PrimerDark.class, NordLight.class, NordDark.class
);
private static final PseudoClass DARK = PseudoClass.getPseudoClass("dark");
private static final PseudoClass USER_CUSTOM = PseudoClass.getPseudoClass("user-custom");
private static final EventBus EVENT_BUS = DefaultEventBus.getInstance();
@ -40,15 +44,20 @@ public final class ThemeManager {
private final Map<String, String> customCSSDeclarations = new LinkedHashMap<>(); // -fx-property | value;
private final Map<String, String> customCSSRules = new LinkedHashMap<>(); // .foo | -fx-property: value;
private Scene scene;
private Pane body;
private final ThemeRepository repository = new ThemeRepository();
private Theme currentTheme = null;
private Scene scene;
private SamplerTheme currentTheme = null;
private String fontFamily = DEFAULT_FONT_FAMILY_NAME;
private int fontSize = DEFAULT_FONT_SIZE;
private int zoom = DEFAULT_ZOOM;
private AccentColor accentColor = DEFAULT_ACCENT_COLOR;
public ThemeRepository getRepository() {
return repository;
}
public Scene getScene() {
return scene;
}
@ -59,46 +68,26 @@ public final class ThemeManager {
this.scene = Objects.requireNonNull(scene);
}
public Theme getTheme() {
public SamplerTheme getTheme() {
return currentTheme;
}
public List<Theme> getAvailableThemes() {
var themes = new ArrayList<Theme>();
var appStylesheets = new URI[] { URI.create(Resources.resolve("assets/styles/index.css")) };
if (Launcher.IS_DEV_MODE) {
themes.add(new ExternalTheme("Primer Light", DUMMY_STYLESHEET, merge(getResource("theme-test/primer-light.css"), appStylesheets), false));
themes.add(new ExternalTheme("Primer Dark", DUMMY_STYLESHEET, merge(getResource("theme-test/primer-dark.css"), appStylesheets), true));
themes.add(new ExternalTheme("Nord Light", DUMMY_STYLESHEET, merge(getResource("theme-test/nord-light.css"), appStylesheets), false));
themes.add(new ExternalTheme("Nord Dark", DUMMY_STYLESHEET, merge(getResource("theme-test/nord-dark.css"), appStylesheets), true));
} else {
themes.add(new PrimerLight(appStylesheets));
themes.add(new PrimerDark(appStylesheets));
themes.add(new NordLight(appStylesheets));
themes.add(new NordDark(appStylesheets));
}
return themes;
public SamplerTheme getDefaultTheme() {
return getRepository().getAll().get(0);
}
/**
* Resets user agent stylesheet and then adds {@link Theme} styles to the {@link Scene}
* stylesheets. This is necessary when we want to reload style changes at runtime, because
* CSSFX doesn't monitor user agent stylesheet.
* Also, some styles aren't applied when using {@link Application#setUserAgentStylesheet(String)} ).
* E.g. JavaFX ignores Ikonli -fx-icon-color and -fx-icon-size properties, but for an unknown
* reason they won't be ignored when exactly the same stylesheet is set via {@link Scene#getStylesheets()}.
*/
public void setTheme(Theme theme) {
/** @see SamplerTheme */
public void setTheme(SamplerTheme theme) {
Objects.requireNonNull(theme);
Application.setUserAgentStylesheet(Objects.requireNonNull(theme.getUserAgentStylesheet()));
if (currentTheme != null) {
getScene().getStylesheets().removeIf(url -> currentTheme.getStylesheets().contains(URI.create(url)));
getScene().getStylesheets().removeIf(s -> theme.getAllStylesheets().contains(s));
}
theme.getStylesheets().forEach(uri -> getScene().getStylesheets().add(uri.toString()));
theme.getAllStylesheets().forEach(s -> getScene().getStylesheets().add(s));
getScene().getRoot().pseudoClassStateChanged(DARK, theme.isDarkMode());
// remove user CSS customizations and reset accent on theme change
@ -133,11 +122,12 @@ public final class ThemeManager {
public void setFontSize(int size) {
if (!SUPPORTED_FONT_SIZE.contains(size)) {
throw new IllegalArgumentException(String.format("Font size must in the range %d-%dpx. Actual value is %d.",
SUPPORTED_FONT_SIZE.get(0),
SUPPORTED_FONT_SIZE.get(SUPPORTED_FONT_SIZE.size() - 1),
size
));
throw new IllegalArgumentException(
String.format("Font size must in the range %d-%dpx. Actual value is %d.",
SUPPORTED_FONT_SIZE.get(0),
SUPPORTED_FONT_SIZE.get(SUPPORTED_FONT_SIZE.size() - 1),
size
));
}
setCustomDeclaration("-fx-font-size", size + "px");
@ -164,10 +154,9 @@ public final class ThemeManager {
public void setZoom(int zoom) {
if (!SUPPORTED_ZOOM.contains(zoom)) {
throw new IllegalArgumentException(String.format("Zoom value must one of %s. Actual value is %d.",
SUPPORTED_ZOOM,
zoom
));
throw new IllegalArgumentException(
String.format("Zoom value must one of %s. Actual value is %d.", SUPPORTED_ZOOM, zoom)
);
}
setFontSize((int) Math.ceil(zoom != 100 ? (DEFAULT_FONT_SIZE * zoom) / 100.0f : DEFAULT_FONT_SIZE));
@ -299,14 +288,6 @@ public final class ThemeManager {
getScene().getRoot().pseudoClassStateChanged(USER_CUSTOM, false);
}
@SafeVarargs
private <T> Set<T> merge(T first, T... arr) {
var set = new LinkedHashSet<T>();
set.add(first);
Collections.addAll(set, arr);
return set;
}
///////////////////////////////////////////////////////////////////////////
// Singleton //
///////////////////////////////////////////////////////////////////////////

@ -0,0 +1,129 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.theme;
import atlantafx.base.theme.*;
import atlantafx.sampler.Resources;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.event.ThemeEvent.EventType;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.prefs.BackingStoreException;
import java.util.prefs.Preferences;
import java.util.stream.Collectors;
import static java.nio.file.LinkOption.NOFOLLOW_LINKS;
public final class ThemeRepository {
private static final Comparator<SamplerTheme> THEME_COMPARATOR = Comparator.comparing(SamplerTheme::getName);
private final List<SamplerTheme> internalThemes = Arrays.asList(
new SamplerTheme(new PrimerLight()),
new SamplerTheme(new PrimerDark()),
new SamplerTheme(new NordLight()),
new SamplerTheme(new NordDark())
);
private final List<SamplerTheme> externalThemes = new ArrayList<>();
private final Preferences themePreferences = Resources.getPreferences().node("theme");
public ThemeRepository() {
try {
loadPreferences();
} catch (BackingStoreException e) {
System.out.println("[WARNING] Unable to load themes from the preferences.");
e.printStackTrace();
}
}
public List<SamplerTheme> getAll() {
var list = new ArrayList<>(internalThemes);
list.addAll(externalThemes);
return list;
}
public SamplerTheme addFromFile(File file) {
Objects.requireNonNull(file);
if (!isFileValid(file.toPath())) {
throw new RuntimeException("Invalid CSS file \"" + file.getAbsolutePath() + "\".");
}
// creating GUI dialogs is hard, so we just obtain theme name from the file name :)
String filename = file.getName();
String themeName = Arrays.stream(filename.replace(".css", "").split("[-_]"))
.map(s -> !s.isEmpty() ? s.substring(0, 1).toUpperCase() + s.substring(1) : "")
.collect(Collectors.joining(" "));
var theme = new SamplerTheme(Theme.of(themeName, file.toString(), filename.contains("dark")));
if (!isUnique(theme)) {
throw new RuntimeException("A theme with the same name or user agent stylesheet already exists in the repository.");
}
addToPreferences(theme);
externalThemes.add(theme);
externalThemes.sort(THEME_COMPARATOR);
DefaultEventBus.getInstance().publish(new ThemeEvent(EventType.THEME_ADD));
return theme;
}
public void remove(SamplerTheme theme) {
Objects.requireNonNull(theme);
externalThemes.removeIf(t -> Objects.equals(t.getName(), theme.getName()));
DefaultEventBus.getInstance().publish(new ThemeEvent(EventType.THEME_REMOVE));
removeFromPreferences(theme);
}
public boolean isFileValid(Path path) {
Objects.requireNonNull(path);
return !Files.isDirectory(path, NOFOLLOW_LINKS) &&
Files.isRegularFile(path, NOFOLLOW_LINKS) &&
Files.isReadable(path) &&
path.getFileName().toString().endsWith(".css");
}
public boolean isUnique(SamplerTheme theme) {
Objects.requireNonNull(theme);
for (SamplerTheme t : getAll()) {
if (Objects.equals(t.getName(), theme.getName()) || Objects.equals(t.getPath(), theme.getPath())) {
return false;
}
}
return true;
}
private void loadPreferences() throws BackingStoreException {
for (String themeName : themePreferences.keys()) {
var uaStylesheet = themePreferences.get(themeName, "");
var uaStylesheetPath = Paths.get(uaStylesheet);
// cleanup broken links, e.g. if theme was added for testing
// but then CSS file was removed from the filesystem
if (!isFileValid(uaStylesheetPath)) {
System.err.println("[WARNING] CSS file invalid or missing: \"" + uaStylesheetPath + "\". Removing silently.");
themePreferences.remove(themeName);
continue;
}
externalThemes.add(new SamplerTheme(
Theme.of(themeName, uaStylesheet, uaStylesheetPath.getFileName().toString().contains("dark"))
));
externalThemes.sort(THEME_COMPARATOR);
}
}
private void addToPreferences(SamplerTheme theme) {
themePreferences.put(theme.getName(), theme.getPath());
}
private void removeFromPreferences(SamplerTheme theme) {
themePreferences.remove(theme.getName());
}
}

@ -5,6 +5,7 @@ module atlantafx.sampler {
requires atlantafx.base;
requires java.desktop;
requires java.prefs;
requires javafx.swing;
requires javafx.media;
requires javafx.web;

@ -28,6 +28,6 @@
>.footer {
-fx-border-width: 1 0 0 0;
-fx-border-color: -color-border-default;
-fx-padding: 10;
-fx-padding: 10px 20px 10px 20px;
}
}

@ -5,3 +5,4 @@
@use "contrast-checker";
@use "accent-color-selector";
@use "quick-config-menu";
@use "theme-repo-manager";

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
@use "color-palette" as palette;
#theme-repo-manager {
-fx-padding: 10px 20px 10px 20px;
-fx-spacing: 10px;
>.info {
-fx-background-color: -color-accent-subtle;
-fx-padding: 0.5em;
-fx-border-width: 1px;
-fx-border-color: -color-accent-muted;
>.label {
-fx-text-fill: -color-accent-fg;
}
}
.scroll-pane {
-fx-border-width: 1px;
-fx-border-color: -color-border-muted;
}
.theme-list {
-fx-padding: 10px;
-fx-spacing: 5px;
>.theme {
-fx-spacing: 10px;
-fx-min-height: 3em;
-fx-padding: 10px;
&:hover {
-fx-background-color: -color-bg-subtle;
}
&:selected {
>.title {
>.text {
-fx-fill: -color-accent-fg;
}
}
}
>.title {
>.sub-text {
-fx-fill: -color-fg-muted;
}
}
>.preview {
>.label {
-fx-alignment: CENTER;
-fx-font-size: 1.2em;
-fx-min-width: 2em;
-fx-pref-width: 2em;
-fx-max-width: 2em;
-fx-min-height: 2em;
-fx-pref-height: 2em;
-fx-max-height: 2em;
}
}
>.controls {
-fx-min-width: 4em;
-fx-pref-width: 4em;
-fx-max-width: 4em;
}
}
}
}
#theme-repo-manager-dialog {
-fx-min-width: 800px;
-fx-pref-width: 800px;
-fx-min-height: 500px;
-fx-max-height: 500px;
}