All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.palexdev.mfxcomponents.theming.UserAgentBuilder Maven / Gradle / Ivy

There is a newer version: 11.26.0
Show newest version
/*
 * Copyright (C) 2023 Parisi Alessandro - [email protected]
 * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX)
 *
 * MaterialFX is free software: you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public License
 * as published by the Free Software Foundation; either version 3 of the License,
 * or (at your option) any later version.
 *
 * MaterialFX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with MaterialFX. If not, see .
 */

package io.github.palexdev.mfxcomponents.theming;

import io.github.palexdev.mfxcomponents.theming.base.Theme;
import io.github.palexdev.mfxcore.utils.fx.CSSFragment;
import javafx.application.Application;
import javafx.scene.Parent;
import javafx.scene.Scene;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;

/**
 * The best way to style a JavaFX application is to use {@link Application#setUserAgentStylesheet(String)} because the
 * styling will be applied everywhere, even in separate windows/scenes. And this is huge for developers that create their
 * own themes. First, it's not needed to track every single window/scene (which means no more worries about
 * dialogs/popups and such not being styled); and second the performance will be much better since the theme will be parsed
 * only once.
 * 

* However, ideally the author of a theme should still use one of the JavaFX's default themes as a base, to make sure that * every control is covered, or maybe because his themes are just for his custom controls (coff coff MaterialFX coff). * For whatever reason, it's always a good idea to have a base. *

* Unfortunately as of now, JavaFX doesn't have a Theming API which would easily allow to create a single theme from a group * of stylesheets. Which means that there is no way to set a user agent stylesheet that can cover all the controls. * There IS a proposal for such API #511, but at the time of writing * this, it's been closed and I don't think we'll see such API in the near future, as always JavaFX development is so SLOW and * clumsy when it comes to new features, it's a shame really. *

* This builder is an attempt at supporting such use cases, and while it's true that the goal can be achieved, it doesn't come * without any caveats that I will discuss later on. *

* The mechanism is rather simple, you can specify a set of themes with {@link #themes(Theme...)}, these will be merged into * a single stylesheet by the {@link #build()} method. The result is a {@link CSSFragment} object that, once converted to * a Data URI through {@link CSSFragment#toDataUri()}, can be set as the {@link Application}'s user agent, can be added * on a {@link Scene} or on a {@link Parent}. The explicit conversion can be avoided by using one of the convenience * method offered by {@link CSSFragment}. *

* With the recent improvements, this build is also capable of managing URL resources and @import statements. *

* Now, let's talk about the caveats. *

* Before the final stylesheet can be feed to {@link CSSFragment}, it needs to be processed by * {@link Processor#preProcess(Theme, String, boolean)} and then by {@link Processor#postProcess(StringBuilder)}. *

* The {@link Processor} is a naive attempt at reading CSS files, for this reason it expects first and foremost well formatted, * uncompressed and beautified CSS files. Even in such conditions, there may be unconsidered/unimplemented cases that could * make the process fail. Report any issue, and I'll see if anything can be done to fix it. *

* Imports and URL resources are supported only if the {@link Theme}s have been deployed and their assets are now accessible * on the filesystem. The processor will attempt to convert any 'local' resource to a path on the disk, and this is * another delicate point of the whole process as the attempt may fail for unexpected reasons. *

* The whole process can be a bit slow, although considering how big are JavaFX's and MaterialFX's themes, and also * considering that now they have to deploy their resources on the disk first, it's really not that bad. * On my main PCs I didn't notice any major slowdowns, at max 1s. On my tablet, which by the way, it's an Android tablet * on which I installed Windows, so take into account that, I noticed the same average. Mileage may vary! *

* Such operations can be enabled/disabled with: {@link #setResolveAssets(boolean)}, {@link #setDeploy(boolean)}. */ public class UserAgentBuilder { //================================================================================ // Properties //================================================================================ private final Set themes = new LinkedHashSet<>(); private boolean resolveAssets = false; private boolean deploy = false; //================================================================================ // Constructors //================================================================================ public UserAgentBuilder() { } public static UserAgentBuilder builder() { return new UserAgentBuilder(); } //================================================================================ // Methods //================================================================================ /** * Allows specifying all the themes that will be merged by {@link #build()}. *

* The themes are stored in a {@link LinkedHashSet}, ensuring insertion order and avoiding duplicates. */ public UserAgentBuilder themes(Theme... themes) { Collections.addAll(this.themes, themes); return this; } /** * Iterates over all the themes added through {@link #themes(Theme...)}. If {@link #isDeploy()} has been set to true * the theme is deployed by {@link Theme#deploy()}. Then the data is loaded with {@link #load(Theme)} and pre-processed, * by {@link Processor#preProcess(Theme, String, boolean)}. The processed data is added to a {@link StringBuilder}. *

* After all the themes have been processed, the data in the {@link StringBuilder} is post-processed by * {@link Processor#postProcess(StringBuilder)} and finally a new {@link CSSFragment} object is created with the * processed data of the merged stylesheet. */ public CSSFragment build() { StringBuilder sb = new StringBuilder(); Processor processor = new Processor(); for (Theme theme : themes) { if (isDeploy()) theme.deploy(); String data = load(theme); String preProcessed = processor.preProcess(theme, data, resolveAssets); sb.append(preProcessed).append("\n\n"); } String postProcess = processor.postProcess(sb); return new CSSFragment(postProcess); } /** * Loads a stylesheet specified by the given {@link Theme}. To achieve this, two streams are used, an {@link InputStream} * which derives from the theme's URL, {@link URL#openStream()}, and a {@link ByteArrayOutputStream}. *

* The stylesheet's contents are read from the input stream and wrote to the output stream. At the end, the latter * is converter to a string by using {@link ByteArrayOutputStream#toString(Charset)} and {@link StandardCharsets#UTF_8}. *

* In case of errors, an empty string is returned, and the exception printed to the stdout. */ protected String load(Theme theme) { try (InputStream is = theme.get().openStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; for (int length; (length = is.read(buffer)) != -1; ) { baos.write(buffer, 0, length); } return baos.toString(StandardCharsets.UTF_8); } catch (Exception ex) { ex.printStackTrace(); return ""; } } //================================================================================ // Getters/Setters //================================================================================ /** * @return whether the {@link Processor} should attempt at resolving @import rules and URL resources */ public boolean isResolveAssets() { return resolveAssets; } /** * Sets whether the {@link Processor} should attempt at resolving @import rules and URL resources. */ public UserAgentBuilder setResolveAssets(boolean resolveAssets) { this.resolveAssets = resolveAssets; return this; } /** * @return whether the builder should invoke {@link Theme#deploy()} before the theme data is processed */ public boolean isDeploy() { return deploy; } /** * Sets whether the builder should invoke {@link Theme#deploy()} before the theme data is processed. */ public UserAgentBuilder setDeploy(boolean deploy) { this.deploy = deploy; return this; } //================================================================================ // Internal Classes //================================================================================ /** * Responsible for pre-processing the themes fed to {@link UserAgentBuilder}, as well as post-processing the * merged stylesheet produced by {@link UserAgentBuilder#build()} before it's returned as a {@link CSSFragment}. *

* This is a basic and naive approach at reading a CSS file. The processor expects well-formatted, uncompressed * and beautified stylesheets. Even in such conditions, the process may fail because on unconsidered/unimplemented * cases. *

* As already said the {@code Processor} splits its job in two phases: pre-process and post-process. *

* During the {@code pre-process} phase, it reads each line of the stylesheet and performs the following modifications: *

- Removes any comment, single and multiple lines *

- Attempts at converting 'relative' @import statements to disk paths, and stores them in a Set *

- Attempts at converting 'relative' URL resources to disk paths *

- Every other kind of line is added without any modification *

* During the {@code post-process} phase, @import statements processed and stored before, are added at the top of the * merged stylesheet. */ private static class Processor { enum Type { START_COMMENT, END_COMMENT, IMPORT, URL, OTHER } private final Set imports = new LinkedHashSet<>(); /** * Given a {@link Theme} and its loaded stylesheet in the form of a single String, performs some modifications on * the data. *

*

- Comments are removed. *

- Imports are resolved and stored is a Set. The resolve is performed by {@link #resolveImport(Theme, String)}. * If the import resource was found in the deployed resources of the theme (see {@link Deployer}), then the * import directive is converted as follows (without quotes): "@import "file:///PATH_ON_THE_DISK";". * It's super important to add the 'file:///' protocol so that the CSS parser can correctly find the resource *

- URLs are resolved by {@link #resolveResource(Theme, String)}. If the resource was found in the deployed * of the theme (see {@link Deployer}), then the URL directive is converted as follows (without quotes): * "url("PATH_ON_THE_DISK");". If the URL points to a network resource, then there's no need to convert it. * * @return the pre-processed theme's data */ public String preProcess(Theme theme, String data, boolean resolveAssets) { String[] lines = data.split("\n"); StringBuilder sb = new StringBuilder(); boolean insideComment = false; for (String line : lines) { if (line.isBlank()) continue; Type type = typeOf(line); /* Ignore comments */ if (type == Type.START_COMMENT) { if (line.trim().endsWith("*/")) continue; /* One line comment */ insideComment = true; continue; } if (type == Type.END_COMMENT) { insideComment = false; continue; } if (insideComment) continue; /* Resolved imports should be stored and added back by the post-processing */ if (type == Type.IMPORT && resolveAssets) { Path path = resolveImport(theme, line); if (path == null || !Files.exists(path)) { System.err.println("Could not resolve import: " + line); continue; } line = "@import \"file:///" + path.toString().replace("\\", "/") + "\";"; imports.add(line); continue; } /* Resolve URLs */ if (type == Type.URL && resolveAssets) { if (!isNetworkResource(line)) { String[] split = line.split(": "); Path path = resolveResource(theme, split[1]); if (path == null || !Files.exists(path)) continue; line = line.replaceAll("^(\\s+).+", "$1") + split[0] + ": url(" + path + ");"; } } if (sb.toString().endsWith("}\n")) sb.append("\n"); sb.append(line).append("\n"); } return sb.toString(); } /** * Given the pre-processed data as a {@link StringBuilder} adds all the imports stored by * {@link #preProcess(Theme, String, boolean)} at the top. * * @return the post-processed data as a String */ public String postProcess(StringBuilder data) { int offset = 0; for (String imp : imports) { data.insert(offset, imp + "\n"); offset += imp.length() + 1; } return data.toString(); } /** * Responsible for resolving the given CSS @import line to a deployed resource on the disk. */ private Path resolveImport(Theme theme, String line) { String[] split = line.replace("\"", "") .replace("'", "") .replace(";", "") .replace("../", "") .split(" "); String path = split[1]; Map deployed = Deployer.instance().getDeployed(theme); return Optional.ofNullable(deployed).map(m -> m.get(path)).orElse(null); } /** * Responsible for resolving the given CSS URL line to a deployed resource on the disk. *

* The resource's name is resolved by {@link #getResourceName(String)}. */ private Path resolveResource(Theme theme, String url) { String name = getResourceName(url); Map deployed = Deployer.instance().getDeployed(theme); return Optional.ofNullable(deployed).map(m -> m.get(name)).orElse(null); } /** * Polishes the given URL string to get the resource's name. */ private String getResourceName(String url) { return url.replace("url(", "").replace(");", ""); } /** * Checks whether the given URL line is a network resource. * A naive approach that checks whether the string contains "http://" or "https://" or "www.". */ private boolean isNetworkResource(String line) { return line.contains("http://") || line.contains("https://") || line.contains("www."); } /** * Given the current CSS line being processed, determines its type. */ private Type typeOf(String line) { String trim = line.trim(); if (trim.startsWith("/*")) return Type.START_COMMENT; if (trim.endsWith("*/")) return Type.END_COMMENT; if (trim.startsWith("@import")) return Type.IMPORT; if (trim.contains("url(")) return Type.URL; return Type.OTHER; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy