Add external themes support
This commit is contained in:
parent
a65ea977c0
commit
9fe8cbde8a
10
CHANGELOG.md
10
CHANGELOG.md
@ -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>
|
||||
|
72
sampler/src/main/java/atlantafx/sampler/FileResource.java
Normal file
72
sampler/src/main/java/atlantafx/sampler/FileResource.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
189
sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java
Normal file
189
sampler/src/main/java/atlantafx/sampler/theme/SamplerTheme.java
Normal file
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user