Improve Sampler layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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