Implement changing accent color

This commit is contained in:
mkpaz 2022-09-02 13:48:11 +04:00
parent 8a7204d93c
commit 21858a3ace
17 changed files with 562 additions and 170 deletions

@ -0,0 +1,23 @@
package atlantafx.sampler.event;
public class ThemeEvent extends Event {
private final EventType eventType;
public ThemeEvent(EventType eventType) {
this.eventType = eventType;
}
public EventType getEventType() {
return eventType;
}
public enum EventType {
// theme can change both, base font size and colors
THEME_CHANGE,
// font size or family only change
FONT_CHANGE,
// colors only change
COLOR_CHANGE
}
}

@ -3,6 +3,7 @@ package atlantafx.sampler.layout;
import atlantafx.sampler.page.Page;
import atlantafx.sampler.page.components.OverviewPage;
import atlantafx.sampler.theme.ThemeManager;
import javafx.animation.FadeTransition;
import javafx.application.Platform;
import javafx.scene.layout.BorderPane;

@ -149,6 +149,7 @@ public abstract class AbstractPage extends BorderPane implements Page {
quickConfigPopover.setHeaderAlwaysVisible(false);
quickConfigPopover.setDetachable(false);
quickConfigPopover.setArrowLocation(TOP_CENTER);
quickConfigPopover.setOnShowing(e -> content.update());
}
quickConfigPopover.show(quickConfigBtn);
@ -169,7 +170,7 @@ public abstract class AbstractPage extends BorderPane implements Page {
// set syntax highlight theme according to JavaFX theme
ThemeManager tm = ThemeManager.getInstance();
codeViewer.setContent(stream, tm.getMatchingHighlightJSTheme(tm.getTheme()));
codeViewer.setContent(stream, tm.getMatchingSourceCodeHighlightTheme(tm.getTheme()));
graphic.setIconCode(ICON_SAMPLE);
codeViewerWrapper.toFront();

@ -2,12 +2,15 @@
package atlantafx.sampler.page;
import atlantafx.base.controls.Spacer;
import atlantafx.sampler.page.general.AccentColorSelector;
import atlantafx.sampler.theme.ThemeManager;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.css.PseudoClass;
import javafx.geometry.HorizontalDirection;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
@ -26,6 +29,8 @@ import java.util.Objects;
import java.util.function.Consumer;
import static atlantafx.base.theme.Styles.*;
import static atlantafx.sampler.theme.ThemeManager.DEFAULT_ZOOM;
import static atlantafx.sampler.theme.ThemeManager.SUPPORTED_ZOOM;
import static javafx.geometry.Pos.CENTER_LEFT;
import static org.kordamp.ikonli.material2.Material2AL.ARROW_BACK;
import static org.kordamp.ikonli.material2.Material2AL.ARROW_FORWARD;
@ -43,14 +48,10 @@ public class QuickConfigMenu extends StackPane {
private Runnable exitHandler;
private final Consumer<String> navHandler = s -> {
Pane pane = null;
Menu menu = null;
switch (s) {
case MainMenu.ID -> pane = getOrCreateMainMenu();
case ThemeSelectionMenu.ID -> {
ThemeSelectionMenu menu = getOrCreateThemeSelectionMenu();
menu.update();
pane = menu;
}
case MainMenu.ID -> menu = getOrCreateMainMenu();
case ThemeSelectionMenu.ID -> menu = getOrCreateThemeSelectionMenu();
default -> {
if (exitHandler != null) {
exitHandler.run();
@ -58,7 +59,10 @@ public class QuickConfigMenu extends StackPane {
}
}
}
getChildren().setAll(Objects.requireNonNull(pane));
Objects.requireNonNull(menu);
menu.update();
getChildren().setAll(menu.getRoot());
};
public void setExitHandler(Runnable exitHandler) {
@ -98,16 +102,30 @@ public class QuickConfigMenu extends StackPane {
return root;
}
public void update() {
getOrCreateMainMenu().update();
}
///////////////////////////////////////////////////////////////////////////
private static class MainMenu extends VBox {
private interface Menu {
void update();
Pane getRoot();
}
private static class MainMenu extends VBox implements Menu {
private static final String ID = "MainMenu";
private static final List<Integer> FONT_SCALE = List.of(
50, 75, 80, 90, 100, 110, 125, 150, 175, 200
);
private final IntegerProperty fontScale = new SimpleIntegerProperty(100);
private final IntegerProperty zoom = new SimpleIntegerProperty(DEFAULT_ZOOM);
private final BooleanBinding canZoomIn = Bindings.createBooleanBinding(
() -> SUPPORTED_ZOOM.indexOf(zoom.get()) < SUPPORTED_ZOOM.size() - 1, zoom
);
private final BooleanBinding canZoomOut = Bindings.createBooleanBinding(
() -> SUPPORTED_ZOOM.indexOf(zoom.get()) >= 1, zoom
);
public MainMenu(Consumer<String> navHandler) {
super();
@ -117,54 +135,66 @@ public class QuickConfigMenu extends StackPane {
var themeSelectionMenu = menu("Theme", HorizontalDirection.RIGHT);
themeSelectionMenu.setOnMouseClicked(e -> navHandler.accept(ThemeSelectionMenu.ID));
var accentSelector = new AccentColorSelector();
accentSelector.setAlignment(Pos.CENTER);
// ~
var zoomInBtn = new Button("", new FontIcon(Feather.ZOOM_IN));
zoomInBtn.getStyleClass().addAll(BUTTON_CIRCLE, BUTTON_ICON, FLAT);
zoomInBtn.setOnAction(e -> {
int idx = FONT_SCALE.indexOf(fontScale.get());
if (idx < FONT_SCALE.size() - 1) { fontScale.set(FONT_SCALE.get(idx + 1)); }
if (canZoomIn.get()) {
zoom.set(SUPPORTED_ZOOM.get(SUPPORTED_ZOOM.indexOf(zoom.get()) + 1));
}
});
zoomInBtn.disableProperty().bind(Bindings.createBooleanBinding(
() -> FONT_SCALE.indexOf(fontScale.get()) >= FONT_SCALE.size() - 1, fontScale)
);
zoomInBtn.disableProperty().bind(canZoomIn.not());
var zoomOutBtn = new Button("", new FontIcon(Feather.ZOOM_OUT));
zoomOutBtn.getStyleClass().addAll(BUTTON_CIRCLE, BUTTON_ICON, FLAT);
zoomOutBtn.setOnAction(e -> {
int idx = FONT_SCALE.indexOf(fontScale.get());
if (idx >= 1) { fontScale.set(FONT_SCALE.get(idx - 1)); }
if (canZoomOut.get()) {
zoom.set(SUPPORTED_ZOOM.get(SUPPORTED_ZOOM.indexOf(zoom.get()) - 1));
}
});
zoomOutBtn.disableProperty().bind(Bindings.createBooleanBinding(
() -> FONT_SCALE.indexOf(fontScale.get()) <= 0, fontScale)
);
zoomOutBtn.disableProperty().bind(canZoomOut.not());
// FIXME: Default zoom value is always 100% which isn't correct because it may have been changed earlier
var zoomLabel = new Label();
zoomLabel.textProperty().bind(Bindings.createStringBinding(() -> fontScale.get() + "%", fontScale));
zoomLabel.textProperty().bind(Bindings.createStringBinding(() -> zoom.get() + "%", zoom));
var zoomBox = new HBox(zoomOutBtn, new Spacer(), zoomLabel, new Spacer(), zoomInBtn);
zoomBox.setAlignment(CENTER_LEFT);
zoomBox.getStyleClass().addAll("row");
final var tm = ThemeManager.getInstance();
fontScale.addListener((obs, old, val) -> {
if (val != null) {
double fontSize = val.intValue() != 100 ?
ThemeManager.DEFAULT_FONT_SIZE / 100.0 * val.intValue() :
ThemeManager.DEFAULT_FONT_SIZE;
tm.setFontSize((int) Math.ceil(fontSize));
tm.reloadCustomCSS();
zoom.addListener((obs, old, val) -> {
if (val != null && tm.getZoom() != val.intValue()) {
tm.setZoom(val.intValue());
}
});
// ~
getChildren().setAll(themeSelectionMenu, new Separator(), zoomBox);
getChildren().setAll(
themeSelectionMenu,
new Separator(),
accentSelector,
new Separator(),
zoomBox
);
}
@Override
public void update() {
zoom.set(ThemeManager.getInstance().getZoom());
}
@Override
public Pane getRoot() {
return this;
}
}
private static class ThemeSelectionMenu extends VBox {
private static class ThemeSelectionMenu extends VBox implements Menu {
public static final String ID = "ThemeSelectionMenu";
@ -174,7 +204,7 @@ public class QuickConfigMenu extends StackPane {
super();
Objects.requireNonNull(navHandler);
final var tm = ThemeManager.getInstance();
var tm = ThemeManager.getInstance();
var mainMenu = menu("Theme", HorizontalDirection.LEFT);
mainMenu.setOnMouseClicked(e -> navHandler.accept(MainMenu.ID));
@ -189,7 +219,6 @@ public class QuickConfigMenu extends StackPane {
item.setUserData(theme.getName());
item.setOnMouseClicked(e -> {
tm.setTheme(theme);
tm.reloadCustomCSS();
navHandler.accept(MainMenu.ID);
navHandler.accept(QuickConfigMenu.EXIT_ID);
});
@ -199,11 +228,17 @@ public class QuickConfigMenu extends StackPane {
});
}
@Override
public void update() {
items.forEach(item -> item.pseudoClassStateChanged(
SELECTED,
Objects.equals(item.getUserData(), ThemeManager.getInstance().getTheme().getName())
));
}
@Override
public Pane getRoot() {
return this;
}
}
}

@ -0,0 +1,53 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import atlantafx.sampler.theme.AccentColor;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.util.JColorUtils;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import static atlantafx.base.theme.Styles.BUTTON_ICON;
import static atlantafx.base.theme.Styles.FLAT;
public class AccentColorSelector extends HBox {
public AccentColorSelector() {
super();
createView();
}
private void createView() {
var resetBtn = new Button("", new FontIcon(Material2AL.CLEAR));
resetBtn.getStyleClass().addAll(BUTTON_ICON, FLAT);
resetBtn.setOnAction(e -> ThemeManager.getInstance().resetAccentColor());
setAlignment(Pos.CENTER_LEFT);
getChildren().setAll(
colorButton(AccentColor.PURPLE),
colorButton(AccentColor.PINK),
colorButton(AccentColor.CORAL),
resetBtn
);
getStyleClass().add("accent-color-selector");
}
private Button colorButton(AccentColor accentColor) {
var icon = new Region();
icon.getStyleClass().add("icon");
var colorMap = accentColor.getColorMap();
var btn = new Button("", icon);
btn.getStyleClass().addAll(BUTTON_ICON, FLAT, "color-button");
btn.setStyle("-color-primary:" + JColorUtils.toHexWithAlpha(colorMap.getPrimaryColor()) + ";");
btn.setUserData(accentColor);
btn.setOnAction(e -> ThemeManager.getInstance().setAccentColor((AccentColor) btn.getUserData()));
return btn;
}
}

@ -32,6 +32,7 @@ import org.kordamp.ikonli.feather.Feather;
import org.kordamp.ikonli.javafx.FontIcon;
import org.kordamp.ikonli.material2.Material2AL;
import java.util.Map;
import java.util.Objects;
import static atlantafx.sampler.page.general.ColorPaletteBlock.validateColorName;
@ -148,10 +149,10 @@ class ContrastChecker extends GridPane {
contrastRatio.addListener((obs, old, val) -> {
if (val == null) { return; }
float ratio = val.floatValue();
updateWsagLabel(aaNormalLabel, ContrastLevel.AA_NORMAL.satisfies(ratio));
updateWsagLabel(aaLargeLabel, ContrastLevel.AA_LARGE.satisfies(ratio));
updateWsagLabel(aaaNormalLabel, ContrastLevel.AAA_NORMAL.satisfies(ratio));
updateWsagLabel(aaaLargeLabel, ContrastLevel.AAA_LARGE.satisfies(ratio));
updateContrastLevelLabel(aaNormalLabel, ContrastLevel.AA_NORMAL.satisfies(ratio));
updateContrastLevelLabel(aaLargeLabel, ContrastLevel.AA_LARGE.satisfies(ratio));
updateContrastLevelLabel(aaaNormalLabel, ContrastLevel.AAA_NORMAL.satisfies(ratio));
updateContrastLevelLabel(aaaLargeLabel, ContrastLevel.AAA_LARGE.satisfies(ratio));
});
// ~
@ -266,12 +267,10 @@ class ContrastChecker extends GridPane {
});
var applyBtn = new Button("Apply");
applyBtn.setOnAction(e -> {
var tm = ThemeManager.getInstance();
tm.setColor(getBgColorName(), bgColor.getColor());
tm.setColor(getFgColorName(), fgColor.getColor());
tm.reloadCustomCSS();
});
applyBtn.setOnAction(e -> ThemeManager.getInstance().setNamedColors(Map.of(
getBgColorName(), bgColor.getColor(),
getFgColorName(), fgColor.getColor()
)));
var controlsBox = new HBox(20, new Spacer(), flattenBtn, applyBtn);
controlsBox.setAlignment(Pos.CENTER_LEFT);
@ -321,8 +320,8 @@ class ContrastChecker extends GridPane {
private void updateStyle() {
setStyle(String.format("-color-contrast-checker-bg:%s;-color-contrast-checker-fg:%s;",
JColorUtils.toHexWithAlpha(bgColor.getColor()),
JColorUtils.toHexWithAlpha(getSafeFgColor())
JColorUtils.toHexWithAlpha(bgColor.getColor()),
JColorUtils.toHexWithAlpha(getSafeFgColor())
));
}
@ -342,7 +341,7 @@ class ContrastChecker extends GridPane {
fgAlphaSlider.setValue(color.getOpacity());
}
private void updateWsagLabel(Label label, boolean success) {
private void updateContrastLevelLabel(Label label, boolean success) {
FontIcon icon = Objects.requireNonNull((FontIcon) label.getGraphic());
if (success) {
label.setText(STATE_PASS);

@ -2,8 +2,9 @@
package atlantafx.sampler.page.general;
import atlantafx.base.theme.Theme;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.theme.ThemeEvent.EventType;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.util.NodeUtils;
import javafx.geometry.HPos;
@ -19,6 +20,8 @@ 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.util.Controls.hyperlink;
public class ThemePage extends AbstractPage {
@ -28,9 +31,9 @@ public class ThemePage extends AbstractPage {
private final Consumer<ColorPaletteBlock> colorBlockActionHandler = colorBlock -> {
ContrastCheckerDialog dialog = getOrCreateContrastCheckerDialog();
dialog.getContent().setValues(colorBlock.getFgColorName(),
colorBlock.getFgColor(),
colorBlock.getBgColorName(),
colorBlock.getBgColor()
colorBlock.getFgColor(),
colorBlock.getBgColorName(),
colorBlock.getBgColor()
);
overlay.setContent(dialog, HPos.CENTER);
overlay.toFront();
@ -47,9 +50,8 @@ public class ThemePage extends AbstractPage {
public ThemePage() {
super();
createView();
ThemeManager.getInstance().addEventListener(e -> {
if (e.eventType() == EventType.THEME_CHANGE || e.eventType() == EventType.CUSTOM_CSS_CHANGE) {
// only works for managed nodes
DefaultEventBus.getInstance().subscribe(ThemeEvent.class, e -> {
if (e.getEventType() == THEME_CHANGE || e.getEventType() == COLOR_CHANGE) {
colorPalette.updateColorInfo(Duration.seconds(1));
colorScale.updateColorInfo(Duration.seconds(1));
}
@ -67,7 +69,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.")
);
@ -89,6 +91,8 @@ public class ThemePage extends AbstractPage {
ChoiceBox<Theme> themeSelector = themeSelector();
themeSelector.setPrefWidth(200);
var accentSelector = new AccentColorSelector();
// ~
var grid = new GridPane();
@ -97,6 +101,8 @@ public class ThemePage extends AbstractPage {
grid.add(new Label("Color theme"), 0, 0);
grid.add(themeSelector, 1, 0);
grid.add(new Label("Accent color"), 0, 1);
grid.add(accentSelector, 1, 1);
return grid;
}
@ -109,7 +115,6 @@ public class ThemePage extends AbstractPage {
if (val != null && getScene() != null) {
var tm = ThemeManager.getInstance();
tm.setTheme(val);
tm.reloadCustomCSS();
}
});

@ -1,9 +1,10 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.general;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.page.AbstractPage;
import atlantafx.sampler.page.SampleBlock;
import atlantafx.sampler.theme.ThemeEvent.EventType;
import atlantafx.sampler.theme.ThemeManager;
import atlantafx.sampler.util.NodeUtils;
import javafx.animation.KeyFrame;
@ -25,6 +26,9 @@ import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import static atlantafx.base.theme.Styles.*;
import static atlantafx.sampler.event.ThemeEvent.EventType.FONT_CHANGE;
import static atlantafx.sampler.event.ThemeEvent.EventType.THEME_CHANGE;
import static atlantafx.sampler.theme.ThemeManager.SUPPORTED_FONT_SIZE;
public class TypographyPage extends AbstractPage {
@ -41,9 +45,8 @@ public class TypographyPage extends AbstractPage {
public TypographyPage() {
super();
createView();
ThemeManager.getInstance().addEventListener(e -> {
if (e.eventType() == EventType.FONT_FAMILY_CHANGE || e.eventType() == EventType.FONT_SIZE_CHANGE) {
// only works for managed nodes
DefaultEventBus.getInstance().subscribe(ThemeEvent.class, e -> {
if (e.getEventType() == THEME_CHANGE || e.getEventType() == FONT_CHANGE) {
updateFontInfo(Duration.seconds(1));
}
});
@ -89,7 +92,6 @@ public class TypographyPage extends AbstractPage {
comboBox.valueProperty().addListener((obs, old, val) -> {
if (val != null) {
tm.setFontFamily(DEFAULT_FONT_ID.equals(val) ? ThemeManager.DEFAULT_FONT_FAMILY_NAME : val);
tm.reloadCustomCSS();
}
});
@ -99,7 +101,11 @@ public class TypographyPage extends AbstractPage {
private Spinner<Integer> fontSizeSpinner() {
final var tm = ThemeManager.getInstance();
var spinner = new Spinner<Integer>(10, 24, tm.getFontSize());
var spinner = new Spinner<Integer>(
SUPPORTED_FONT_SIZE.get(0),
SUPPORTED_FONT_SIZE.get(SUPPORTED_FONT_SIZE.size() - 1),
tm.getFontSize()
);
spinner.getStyleClass().add(Spinner.STYLE_CLASS_SPLIT_ARROWS_HORIZONTAL);
spinner.setPrefWidth(CONTROL_WIDTH);
@ -113,7 +119,6 @@ public class TypographyPage extends AbstractPage {
spinner.valueProperty().addListener((obs, old, val) -> {
if (val != null) {
tm.setFontSize(val);
tm.reloadCustomCSS();
updateFontInfo(Duration.seconds(1));
}
});

@ -0,0 +1,18 @@
package atlantafx.sampler.theme;
public enum AccentColor {
PURPLE(ColorMap.primerPurple()),
PINK(ColorMap.primerPink()),
CORAL(ColorMap.primerCoral());
private final ColorMap colorMap;
AccentColor(ColorMap colorMap) {
this.colorMap = colorMap;
}
public ColorMap getColorMap() {
return colorMap;
}
}

@ -0,0 +1,146 @@
package atlantafx.sampler.theme;
import javafx.scene.paint.Color;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import static atlantafx.sampler.util.JColorUtils.opaqueColor;
// Theoretically, one should use different accent shades for each color theme
// and for both light and dark mode. But since creating color palettes is
// pretty time-consuming, dynamic color calculation based on opacity should
// suit demo purposes.
public class ColorMap {
private static final String MUTED_COLOR_NAME = "-color-accent-muted";
private static final String SUBTLE_COLOR_NAME = "-color-accent-subtle";
private final Map<String, Color> allColors = new HashMap<>();
private final Map<String, Color> dynamicColors = new HashMap<>();
private final Color primaryColor;
private ColorMap(Color primaryColor) {
this.primaryColor = primaryColor;
}
public Color getPrimaryColor() {
return primaryColor;
}
public Map<String, Color> getAll() {
return new HashMap<>(allColors);
}
private void setColor(String colorName, Color colorValue) {
allColors.put(Objects.requireNonNull(colorName), colorValue);
}
private void setDynamicColor(String colorName, Color colorValue) {
dynamicColors.put(Objects.requireNonNull(colorName), colorValue);
}
void update(Color background) {
allColors.put(MUTED_COLOR_NAME, opaqueColor(background, dynamicColors.get(MUTED_COLOR_NAME), 0.5));
allColors.put(SUBTLE_COLOR_NAME, opaqueColor(background, dynamicColors.get(SUBTLE_COLOR_NAME), 0.4));
}
///////////////////////////////////////////////////////////////////////////
public static ColorMap primerPurple() {
var map = new ColorMap(Color.web("#8250df"));
map.setColor("-color-blue-0", Color.web("#fbefff"));
map.setColor("-color-blue-1", Color.web("#ecd8ff"));
map.setColor("-color-blue-2", Color.web("#d8b9ff"));
map.setColor("-color-blue-3", Color.web("#c297ff"));
map.setColor("-color-blue-4", Color.web("#a475f9"));
map.setColor("-color-blue-5", Color.web("#8250df"));
map.setColor("-color-blue-6", Color.web("#6639ba"));
map.setColor("-color-blue-7", Color.web("#512a97"));
map.setColor("-color-blue-8", Color.web("#3e1f79"));
map.setColor("-color-blue-9", Color.web("#2e1461"));
map.setColor("-color-accent-fg", Color.web("#8250df"));
map.setColor("-color-accent-emphasis", Color.web("#8250df"));
map.setColor(MUTED_COLOR_NAME, Color.web("#d8b9ff"));
map.setColor(SUBTLE_COLOR_NAME, Color.web("#fbefff"));
map.setDynamicColor(MUTED_COLOR_NAME, Color.web("#d8b9ff"));
map.setDynamicColor(SUBTLE_COLOR_NAME, Color.web("#fbefff"));
return map;
}
public static ColorMap primerPink() {
var map = new ColorMap(Color.web("#bf3989"));
map.setColor("-color-blue-0", Color.web("#ffeff7"));
map.setColor("-color-blue-1", Color.web("#ffd3eb"));
map.setColor("-color-blue-2", Color.web("#ffadda"));
map.setColor("-color-blue-3", Color.web("#ff80c8"));
map.setColor("-color-blue-4", Color.web("#e85aad"));
map.setColor("-color-blue-5", Color.web("#bf3989"));
map.setColor("-color-blue-6", Color.web("#99286e"));
map.setColor("-color-blue-7", Color.web("#772057"));
map.setColor("-color-blue-8", Color.web("#611347"));
map.setColor("-color-blue-9", Color.web("#4d0336"));
map.setColor("-color-accent-fg", Color.web("#bf3989"));
map.setColor("-color-accent-emphasis", Color.web("#bf3989"));
map.setColor(MUTED_COLOR_NAME, Color.web("#ffadda"));
map.setColor(SUBTLE_COLOR_NAME, Color.web("#ffeff7"));
map.setDynamicColor(MUTED_COLOR_NAME, Color.web("#ffadda"));
map.setDynamicColor(SUBTLE_COLOR_NAME, Color.web("#ffeff7"));
return map;
}
public static ColorMap primerCoral() {
var map = new ColorMap(Color.web("#c4432b"));
map.setColor("-color-blue-0", Color.web("#fff0eb"));
map.setColor("-color-blue-1", Color.web("#ffd6cc"));
map.setColor("-color-blue-2", Color.web("#ffb4a1"));
map.setColor("-color-blue-3", Color.web("#fd8c73"));
map.setColor("-color-blue-4", Color.web("#ec6547"));
map.setColor("-color-blue-5", Color.web("#c4432b"));
map.setColor("-color-blue-6", Color.web("#9e2f1c"));
map.setColor("-color-blue-7", Color.web("#801f0f"));
map.setColor("-color-blue-8", Color.web("#691105"));
map.setColor("-color-blue-9", Color.web("#510901"));
map.setColor("-color-accent-fg", Color.web("#c4432b"));
map.setColor("-color-accent-emphasis", Color.web("#c4432b"));
map.setColor(MUTED_COLOR_NAME, Color.web("#ffb4a1"));
map.setColor(SUBTLE_COLOR_NAME, Color.web("#fff0eb"));
map.setDynamicColor(MUTED_COLOR_NAME, Color.web("#ffb4a1"));
map.setDynamicColor(SUBTLE_COLOR_NAME, Color.web("#fff0eb"));
return map;
}
// empty map contains only color names and used to reset
// accent color scale to its initial state
static ColorMap empty() {
var map = new ColorMap(Color.web("#bf3989"));
map.setColor("-color-blue-0", null);
map.setColor("-color-blue-1", null);
map.setColor("-color-blue-2", null);
map.setColor("-color-blue-3", null);
map.setColor("-color-blue-4", null);
map.setColor("-color-blue-5", null);
map.setColor("-color-blue-6", null);
map.setColor("-color-blue-7", null);
map.setColor("-color-blue-8", null);
map.setColor("-color-blue-9", null);
map.setColor("-color-accent-fg", null);
map.setColor("-color-accent-emphasis", null);
map.setColor(MUTED_COLOR_NAME, null);
map.setColor(SUBTLE_COLOR_NAME, null);
return map;
}
}

@ -1,11 +0,0 @@
package atlantafx.sampler.theme;
public record ThemeEvent(EventType eventType) {
public enum EventType {
THEME_CHANGE,
FONT_FAMILY_CHANGE,
FONT_SIZE_CHANGE,
CUSTOM_CSS_CHANGE
}
}

@ -4,7 +4,10 @@ package atlantafx.sampler.theme;
import atlantafx.base.theme.*;
import atlantafx.sampler.Launcher;
import atlantafx.sampler.Resources;
import atlantafx.sampler.theme.ThemeEvent.EventType;
import atlantafx.sampler.event.DefaultEventBus;
import atlantafx.sampler.event.EventBus;
import atlantafx.sampler.event.ThemeEvent;
import atlantafx.sampler.event.ThemeEvent.EventType;
import atlantafx.sampler.util.JColor;
import javafx.application.Application;
import javafx.css.PseudoClass;
@ -13,46 +16,67 @@ import javafx.scene.paint.Color;
import java.net.URI;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static atlantafx.sampler.Resources.getResource;
import static java.nio.charset.StandardCharsets.UTF_8;
public final class ThemeManager {
private static final String DUMMY_STYLESHEET = Resources.getResource("assets/styles/empty.css").toString();
private static final String DUMMY_STYLESHEET = getResource("assets/styles/empty.css").toString();
private static final PseudoClass USER_CUSTOM = PseudoClass.getPseudoClass("user-custom");
private static final EventBus EVENT_BUS = DefaultEventBus.getInstance();
public static final String DEFAULT_FONT_FAMILY_NAME = "Inter";
public static final int DEFAULT_FONT_SIZE = 14;
public static final int DEFAULT_ZOOM = 100;
public static final AccentColor DEFAULT_ACCENT_COLOR = null;
public static final List<Integer> SUPPORTED_FONT_SIZE = IntStream.range(8, 29).boxed().collect(Collectors.toList());
public static final List<Integer> SUPPORTED_ZOOM = List.of(50, 75, 80, 90, 100, 110, 125, 150, 175, 200);
// KEY | VALUE
// -fx-property | value;
private final Map<String, String> customCSSDeclarations = new LinkedHashMap<>();
// .foo | -fx-property: value;
private final Map<String, String> customCSSRules = new LinkedHashMap<>();
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 Theme currentTheme = null;
private String fontFamily = DEFAULT_FONT_FAMILY_NAME;
private int fontSize = DEFAULT_FONT_SIZE;
private final List<Consumer<ThemeEvent>> eventListeners = new ArrayList<>();
private int zoom = DEFAULT_ZOOM;
private AccentColor accentColor = DEFAULT_ACCENT_COLOR;
public Scene getScene() {
return scene;
}
public void addEventListener(Consumer<ThemeEvent> listener) {
eventListeners.add(Objects.requireNonNull(listener));
}
// MUST BE SET ON STARTUP
// (this is supposed to be a constructor arg, but since app don't use DI..., sorry)
public void setScene(Scene scene) {
this.scene = scene;
this.scene = Objects.requireNonNull(scene);
}
public Theme 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;
}
/**
* 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
@ -62,7 +86,6 @@ public final class ThemeManager {
* reason they won't be ignored when exactly the same stylesheet is set via {@link Scene#getStylesheets()}.
*/
public void setTheme(Theme theme) {
Objects.requireNonNull(scene);
Objects.requireNonNull(theme);
Application.setUserAgentStylesheet(Objects.requireNonNull(theme.getUserAgentStylesheet()));
@ -72,38 +95,10 @@ public final class ThemeManager {
}
theme.getStylesheets().forEach(uri -> scene.getStylesheets().add(uri.toString()));
resetCustomCSS();
currentTheme = theme;
notifyEventListeners(EventType.THEME_CHANGE);
}
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(
Resources.getResource("theme-test/primer-light.css"),
appStylesheets
), false));
themes.add(new ExternalTheme("Primer Dark", DUMMY_STYLESHEET, merge(
Resources.getResource("theme-test/primer-dark.css"),
appStylesheets
), true));
themes.add(new ExternalTheme("Nord Light", DUMMY_STYLESHEET, merge(
Resources.getResource("theme-test/nord-light.css"),
appStylesheets
), false));
themes.add(new ExternalTheme("Nord Dark", DUMMY_STYLESHEET, merge(
Resources.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;
EVENT_BUS.publish(new ThemeEvent(EventType.THEME_CHANGE));
}
public String getFontFamily() {
@ -113,8 +108,11 @@ public final class ThemeManager {
public void setFontFamily(String fontFamily) {
Objects.requireNonNull(fontFamily);
setCustomDeclaration("-fx-font-family", "\"" + fontFamily + "\"");
this.fontFamily = fontFamily;
notifyEventListeners(EventType.FONT_FAMILY_CHANGE);
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.FONT_CHANGE));
}
public boolean isDefaultFontFamily() {
@ -125,26 +123,109 @@ public final class ThemeManager {
return fontSize;
}
public void setFontSize(int fontSize) {
setCustomDeclaration("-fx-font-size", fontSize + "px");
setCustomRule(".ikonli-font-icon", String.format("-fx-icon-size: %dpx;", fontSize + 2));
this.fontSize = fontSize;
notifyEventListeners(EventType.FONT_SIZE_CHANGE);
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
));
}
setCustomDeclaration("-fx-font-size", size + "px");
setCustomRule(".ikonli-font-icon", String.format("-fx-icon-size: %dpx;", size + 2));
this.fontSize = size;
var rawZoom = (int) Math.ceil((size * 1.0 / DEFAULT_FONT_SIZE) * 100);
this.zoom = SUPPORTED_ZOOM.stream()
.min(Comparator.comparingInt(i -> Math.abs(i - rawZoom)))
.orElseThrow(NoSuchElementException::new);
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.FONT_CHANGE));
}
public void setColor(String colorName, Color color) {
Objects.requireNonNull(colorName);
public boolean isDefaultSize() {
return DEFAULT_FONT_SIZE == fontSize;
}
public int getZoom() {
return zoom;
}
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
));
}
setFontSize((int) Math.ceil(zoom != 100 ? (DEFAULT_FONT_SIZE * zoom) / 100.0f : DEFAULT_FONT_SIZE));
this.zoom = zoom;
}
public AccentColor getAccentColor() {
return accentColor;
}
public void setAccentColor(AccentColor color) {
Objects.requireNonNull(color);
setCustomDeclaration(colorName, JColor.color(
(float) color.getRed(), (float) color.getGreen(), (float) color.getBlue(), (float) color.getOpacity()).getColorHexWithAlpha()
);
var colorMap = color.getColorMap();
// adapt color map to the current theme
if (!getTheme().isDarkMode()) {
colorMap.update(Color.WHITE);
} else {
colorMap.update(Color.BLACK);
}
applyColorMap(colorMap);
this.accentColor = color;
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.COLOR_CHANGE));
}
public void resetColor(String colorName) {
Objects.requireNonNull(colorName);
removeCustomDeclaration(colorName);
public void resetAccentColor() {
applyColorMap(ColorMap.empty());
this.accentColor = null;
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.COLOR_CHANGE));
}
public void setNamedColors(Map<String, Color> colors) {
Objects.requireNonNull(colors).forEach(this::setOrRemoveColor);
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.COLOR_CHANGE));
}
public void unsetNamedColors(String... colors) {
for (String c : colors) {
setOrRemoveColor(c, null);
}
reloadCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.COLOR_CHANGE));
}
public void resetAllChanges() {
resetCustomCSS();
EVENT_BUS.publish(new ThemeEvent(EventType.THEME_CHANGE));
}
public HighlightJSTheme getMatchingSourceCodeHighlightTheme(Theme theme) {
Objects.requireNonNull(theme);
if ("Nord Light".equals(theme.getName())) { return HighlightJSTheme.nordLight(); }
if ("Nord Dark".equals(theme.getName())) { return HighlightJSTheme.nordDark(); }
return theme.isDarkMode() ? HighlightJSTheme.githubDark() : HighlightJSTheme.githubLight();
}
///////////////////////////////////////////////////////////////////////////
private void setCustomDeclaration(String property, String value) {
customCSSDeclarations.put(property, value);
}
@ -161,7 +242,25 @@ public final class ThemeManager {
customCSSRules.remove(selector);
}
public void reloadCustomCSS() {
private void setOrRemoveColor(String colorName, Color color) {
Objects.requireNonNull(colorName);
if (color != null) {
setCustomDeclaration(colorName, JColor.color(
(float) color.getRed(),
(float) color.getGreen(),
(float) color.getBlue(),
(float) color.getOpacity()).getColorHexWithAlpha()
);
} else {
removeCustomDeclaration(colorName);
}
}
private void applyColorMap(ColorMap colorMap) {
colorMap.getAll().forEach(this::setOrRemoveColor);
}
private void reloadCustomCSS() {
Objects.requireNonNull(scene);
StringBuilder css = new StringBuilder();
@ -192,26 +291,12 @@ public final class ThemeManager {
"data:text/css;base64," + Base64.getEncoder().encodeToString(css.toString().getBytes(UTF_8))
);
scene.getRoot().pseudoClassStateChanged(USER_CUSTOM, true);
notifyEventListeners(EventType.CUSTOM_CSS_CHANGE);
}
public void resetCustomCSS() {
customCSSDeclarations.clear();
customCSSRules.clear();
scene.getRoot().pseudoClassStateChanged(USER_CUSTOM, false);
notifyEventListeners(EventType.CUSTOM_CSS_CHANGE);
}
public HighlightJSTheme getMatchingHighlightJSTheme(Theme theme) {
Objects.requireNonNull(theme);
if ("Nord Light".equals(theme.getName())) { return HighlightJSTheme.nordLight(); }
if ("Nord Dark".equals(theme.getName())) { return HighlightJSTheme.nordDark(); }
return theme.isDarkMode() ? HighlightJSTheme.githubDark() : HighlightJSTheme.githubLight();
}
public void notifyEventListeners(EventType eventType) {
var e = new ThemeEvent(eventType);
eventListeners.forEach(l -> l.accept(e));
}
@SafeVarargs

@ -26,7 +26,6 @@ package atlantafx.sampler.util;
import javafx.scene.paint.Color;
import java.util.Arrays;
import java.util.regex.Pattern;
/**
@ -803,13 +802,13 @@ public class JColorUtils {
* <br/>
* <a href="https://filosophy.org/code/online-tool-to-lighten-color-without-alpha-channel/">Source</a>.
*/
public static double[] flattenColor(Color bgColor, Color fgColor) {
public static double[] flattenColor(Color bg, Color fgColor) {
var opacity = fgColor.getOpacity();
return opacity < 1 ?
new double[] {
opacity * fgColor.getRed() + (1 - opacity) * bgColor.getRed(),
opacity * fgColor.getGreen() + (1 - opacity) * bgColor.getGreen(),
opacity * fgColor.getBlue() + (1 - opacity) * bgColor.getBlue(),
opacity * fgColor.getRed() + (1 - opacity) * bg.getRed(),
opacity * fgColor.getGreen() + (1 - opacity) * bg.getGreen(),
opacity * fgColor.getBlue() + (1 - opacity) * bg.getBlue(),
} :
new double[] {
fgColor.getRed(),
@ -818,6 +817,19 @@ public class JColorUtils {
};
}
/**
* The opposite to the {@link JColorUtils#flattenColor(Color, Color)}. It converts target opaque color
* to its equivalent with the desired opacity level.
*/
public static Color opaqueColor(Color bgColor, Color targetColor, double targetOpacity) {
return Color.color(
bgColor.getRed() + (targetColor.getRed() - bgColor.getRed()) * targetOpacity,
bgColor.getGreen() + (targetColor.getGreen() - bgColor.getGreen()) * targetOpacity,
bgColor.getBlue() + (targetColor.getBlue() - bgColor.getBlue()) * targetOpacity,
targetOpacity
);
}
public static float[] toHSL(Color color) {
return JColorUtils.toHSL(
(float) color.getRed(),

@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
.accent-color-selector {
-color-primary: -color-accent-emphasis;
-fx-spacing: 1em;
>.color-button {
>.icon {
-fx-min-width: 1em;
-fx-pref-width: 1em;
-fx-max-height: 1em;
-fx-min-width: 1em;
-fx-pref-height: 1em;
-fx-max-height: 1em;
-fx-background-color: -color-primary;
}
}
}

@ -1,8 +1,8 @@
// SPDX-License-Identifier: MIT
$color-wsag-bg-passed: #388e3c;
$color-wsag-bg-failed: #ef5350;
$color-wsag-fg: white;
$color-wcag-bg-passed: #388e3c;
$color-wcag-bg-failed: #ef5350;
$color-wcag-fg: white;
#color-palette {
@ -24,18 +24,18 @@ $color-wsag-fg: white;
-fx-cursor: hand;
&:passed>.contrast-level-label {
-fx-background-color: $color-wsag-bg-passed;
-fx-background-color: $color-wcag-bg-passed;
}
>.contrast-level-label {
-fx-text-fill: $color-wsag-fg;
-fx-background-color: $color-wsag-bg-failed;
-fx-text-fill: $color-wcag-fg;
-fx-background-color: $color-wcag-bg-failed;
-fx-background-radius: 6px;
-fx-padding: 3px;
>.ikonli-font-icon {
-fx-fill: $color-wsag-fg;
-fx-icon-color: $color-wsag-fg;
-fx-fill: $color-wcag-fg;
-fx-icon-color: $color-wcag-fg;
}
}
}

@ -79,17 +79,17 @@
.contrast-level {
>.state {
-fx-padding: 0.5em 1em 0.5em 1em;
-fx-background-color: palette.$color-wsag-bg-failed;
-fx-background-color: palette.$color-wcag-bg-failed;
-fx-background-radius: 4px;
-fx-text-fill: palette.$color-wsag-fg;
-fx-text-fill: palette.$color-wcag-fg;
&:passed {
-fx-background-color: palette.$color-wsag-bg-passed;
-fx-background-color: palette.$color-wcag-bg-passed;
}
>.ikonli-font-icon {
-fx-fill: palette.$color-wsag-fg;
-fx-icon-color: palette.$color-wsag-fg;
-fx-fill: palette.$color-wcag-fg;
-fx-icon-color: palette.$color-wcag-fg;
}
}
}

@ -3,4 +3,5 @@
@use "color-palette";
@use "color-scale";
@use "contrast-checker";
@use "accent-color-selector";
@use "quick-config-menu";