Add bbcode markup support

This commit is contained in:
mkpaz 2023-05-01 20:18:41 +04:00
parent 6584c53906
commit 0489d6c3e5
7 changed files with 1699 additions and 3 deletions

@ -7,6 +7,7 @@
- (Base) New `DeckPane` component with swipe and slide transition support.
- (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input.
- (Base) New `PasswordTextField` component to simplify `PasswordTextFormatter` usage.
- (Base) 🚀 [BBCode](https://ru.wikipedia.org/wiki/BBCode) markup support.
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).

@ -0,0 +1,758 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.util;
import atlantafx.base.theme.Styles;
import java.util.ArrayDeque;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.RowConstraints;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextAlignment;
import javafx.scene.text.TextFlow;
import org.jetbrains.annotations.Nullable;
/**
* Basic handler interface for the {@link BBCodeParser} that will
* receive notifications while processing user input text.
*/
public interface BBCodeHandler {
/**
* Notifies that parsing has started.
*
* @param doc parser input string
*/
void startDocument(char[] doc);
/**
* Notifies that parsing has finished.
*/
void endDocument();
/**
* Notifies about the start of the tag.
* In case of self-closing tag this also notifies about the end of the tag.
*
* @param name tag name
* @param params tag params
* @param start tag start position, i.e. the position of open square bracket (not the tag name start)
* @param length tag length, including closing bracket
*/
void startTag(String name, @Nullable Map<String, String> params, int start, int length);
/**
* Notifies about the end of the tag.
* In case of self-closing tag only {@link #startTag(String, Map, int, int)} method is called.
*
* @param name tag name
* @param start tag start position, i.e. the position of open square bracket (not the tag name start)
* @param length tag length, including closing bracket
*/
void endTag(String name, int start, int length);
/**
* Notifies about characters data that doesn't belong to any tag, i.e.
* leading, intermediate or trailing text.
*
* @param start text start position
* @param length text length
*/
void characters(int start, int length);
///////////////////////////////////////////////////////////////////////////
/**
* A basic {@link BBCodeHandler} implementation.<br/><br/>
*
* <p>While parsing all created nodes will be added to the given root container.
* The choice depends on the actual markup. Default constructor accepts any {@link Pane}
* or its descendant. Using {@link TextFlow} for text-only markup (no block nodes) and
* {@link VBox} otherwise, is recommended.<br/><br/>
*
* <h3>Supported tags</h3><br/>
* <pre>
* Bold Text : text : [b]{text}[/b]
* Italic Text : text : [i]{text}[/i]
* Underlined Text : text : [u]{text}[/u]
* Strikethrough Text : text : [s]{text}[/s]
* Font Color : text : [color={color}]{text}[/color]
* Font Family : text : [font={monospace}]{text}[/font]
* Font Size : text : [size={size}]{text}[/size]
* Link : text : [url={url}]{text}[/url]
* [url url={url} class={class}]{text}[/url]
* Email : text : [email]{text}[/email]
* [email email={url} class={class}]{text}[/email]
* Style : text : [style={style}]{text}[/style]
* Subscript : text : [sub]{text}[/sub]
* Superscript : text : [sup]{text}[/sup]
* Heading : text : [heading]{text}[/heading]
* [heading={level}]{text}[/heading]
* Code : text : [code]{text}[/code]
* [code={class}]{text}[/code]
* Span : text : [span]{text}[/span]
* [span={class}]{text}[/span]
* [span style={style} class={class}]{text}[/span]
* Label : text : [label]{text}[/label]
* [label={class}]{text}[/label]
* [label style={style} class={class}]{text}[/label]
* Caption Text : text : [caption]{text}[/caption]
* Small Text : text : [small]{text}[/small]
* Abbreviation : text : [abbr="tooltip text"]{text}[/abbr]
* Unordered List : block : [ul]
* [li]Entry 1[/li]
* [li]Entry 2[/li]
* [/ul]
* [ul={bullet character}]
* [li]Entry 1[/li]
* [li]Entry 2[/li]
* [/ul]
* Ordered List : block : [ol]
* [li]Entry 1[/li]
* [li]Entry 2[/li]
* [/ol]
* [ol={start number or letter}]
* [li]Entry 1[/li]
* [li]Entry 2[/li]
* [/ol]
* Alignment : block : [left]{content}[/left]
* [center]{content}[/center]
* [right]{content}[/right]
* [align={javafx.geometry.Pos}]{content}[/align]
* Indentation : block : [indent]{content}[/indent]
* [indent=level]{content}[/indent]
* Horizontal Rule : block : [hr/]
* [hr=thickness/]
* </pre>
*
* <ul>
* <li>If tag param contains whitespaces or trailing slash is must be
* enclosed in double or single quotes.
* <li>If tag only has a single param, it can be shortened to {@code [name=value]{text}[/name]}.
* In that case tag param name considered to be equal to the tag name.
* <li>Unknown tag params will be ignored.
* </ul>
*
* <h3>Action Events</h3><br/>
* Some nodes, e.g. {@link Hyperlink} require action handlers. To avoid traversing
* the root node graph you can add an event filter.
*
* <pre>{@code
* var input = "Visit the [url=https://example.com]website[/url].";
* var textFlow = BBCodeParser.createLayout(input);
* textFlow.addEventFilter(ActionEvent.ACTION, e-> {
* if (e.getTarget() instanceof Hyperlink link) {
* openURL(link.getUserData());
* }
* e.consume();
* });}
* </pre>
*/
class Default<T extends Pane> implements BBCodeHandler {
protected static final int OL_LETTER_OFFSET = 100_000;
protected final Block root;
protected char[] doc;
protected Deque<Tag> openTags = new ArrayDeque<>();
protected Deque<Block> openBlocks = new ArrayDeque<>();
protected int textCursor;
/**
* Creates a new handler instance.
*
* @param root root container
*/
public Default(T root) {
Objects.requireNonNull(root, "Root container cannot be null.");
this.root = root instanceof TextFlow tf ? new Block(root, tf) : new Block(root, new TextFlow());
this.root.node().getStyleClass().add("bb-code");
}
@Override
public void startDocument(char[] doc) {
this.doc = doc;
}
@Override
public void endDocument() {
this.doc = null;
}
@Override
public void startTag(String name, @Nullable Map<String, String> params, int start, int length) {
Tag tag = createTag(name, params);
// ignore unknown tags
if (tag == null) {
return;
}
// append all text before the current cursor position
if (!openTags.isEmpty()) {
appendTextToCurrentBranch(openTags.getFirst(), textCursor, start - textCursor);
}
if (!tag.isSelfClose()) {
// push newly opened tag on top
openTags.addFirst(tag);
// if tag is a block or an inline block, update branch reference
if (tag.isBlock()) {
createBranch();
}
} else {
appendSelfCloseTag(tag);
}
// move text cursor to the first char inside the new tag
// (or to the first char after the self-close tag)
textCursor = start + length;
}
@Override
public void endTag(String name, int start, int length) {
// closing the first tag node from stack
Tag tag = openTags.getFirst();
// append all text before the current cursor position
// and move text cursor to the first char after the closing tag
appendTextToCurrentBranch(tag, textCursor, start - textCursor);
textCursor = start + length;
openTags.removeFirst(); // close tag
if (tag.isBlock()) { // return to the parent node
openBlocks.removeFirst();
}
}
@Override
public void characters(int start, int length) {
if (length > 0) {
var text = new Text(new String(doc, start, length));
if (root.node() instanceof TextFlow) {
// support special use case for simple markup
root.children().add(text);
} else {
// otherwise text is always appended to the root,
// this also creates a new TextFlow if necessary
appendTextToRoot(text);
}
}
}
protected @Nullable Tag createTag(String name, @Nullable Map<String, String> params) {
Tag.Type tagType = null;
// all styles added here will be inherited by nested tags
Set<String> stylesClass = new HashSet<>();
Set<String> style = new HashSet<>();
// all supported tags must be listed here
switch (name) {
// == TEXT STYLE ==
case "b" -> {
stylesClass.add(Styles.TEXT_BOLD);
tagType = Tag.Type.TEXT;
}
case "i" -> {
stylesClass.add(Styles.TEXT_ITALIC);
tagType = Tag.Type.TEXT;
}
case "u" -> {
stylesClass.add(Styles.TEXT_UNDERLINED);
tagType = Tag.Type.TEXT;
}
case "s" -> {
stylesClass.add(Styles.TEXT_STRIKETHROUGH);
tagType = Tag.Type.TEXT;
}
case "color" -> {
addStyleIfPresent(params, "-fx-fill", "color", style);
tagType = Tag.Type.TEXT;
}
case "font" -> {
addStyleIfPresent(params, "-fx-font-family", "font", style);
tagType = Tag.Type.TEXT;
}
case "style" -> {
addStyleIfPresent(params, "style", style, ";");
tagType = Tag.Type.TEXT;
}
case "sub" -> {
stylesClass.add("sub");
tagType = Tag.Type.TEXT;
}
case "sup" -> {
stylesClass.add("sup");
tagType = Tag.Type.TEXT;
}
// == TEXT SIZE ==
case "heading" -> {
if (params != null && params.containsKey("heading")) {
stylesClass.add("title-" + getParamOrDefault(params, "heading", "3"));
}
stylesClass.add("heading");
tagType = Tag.Type.TEXT;
}
case "caption" -> {
stylesClass.add(Styles.TEXT_CAPTION);
tagType = Tag.Type.TEXT;
}
case "size" -> {
addStyleIfPresent(params, "-fx-font-size", "size", style);
tagType = Tag.Type.TEXT;
}
case "small" -> {
stylesClass.add(Styles.TEXT_SMALL);
tagType = Tag.Type.TEXT;
}
// LINKS
case "url", "email" -> {
// support style class to differentiate controls in action handler
addStyleIfPresent(params, "class", stylesClass, " ");
tagType = Tag.Type.TEXT;
}
// == CUSTOM FORMATTING ==
case "code" -> {
stylesClass.add("code");
tagType = Tag.Type.TEXT;
}
case "span" -> {
addStyleIfPresent(params, "span", stylesClass, " ");
addStyleIfPresent(params, "class", stylesClass, " ");
addStyleIfPresent(params, "style", style, ";");
tagType = Tag.Type.TEXT;
}
case "label" -> {
addStyleIfPresent(params, "label", stylesClass, " ");
addStyleIfPresent(params, "class", stylesClass, " ");
addStyleIfPresent(params, "style", style, ";");
tagType = Tag.Type.TEXT;
}
// INTERACTIVE
case "abbr" -> {
stylesClass.add("abbr");
tagType = Tag.Type.TEXT;
}
// == BLOCKS ==
case "ol", "ul" -> {
addStyleIfPresent(params, "class", stylesClass, " ");
tagType = Tag.Type.BLOCK;
}
case "align", "li", "left", "center", "right", "indent" -> tagType = Tag.Type.BLOCK;
// == SELF-CLOSE ==
case "hr" -> tagType = Tag.Type.SELF_CLOSE;
}
return tagType != null ? new Tag(name, tagType, params, stylesClass, style) : null;
}
protected void appendTextToRoot(Node node) {
TextFlow parent;
// append to the last TextFlow node, create one if not present
if (root.isEmpty() || !(root.children().get(root.size() - 1) instanceof TextFlow)) {
parent = new TextFlow();
root.children().add(parent);
} else {
parent = (TextFlow) root.children().get(root.size() - 1);
}
parent.getChildren().add(node);
}
protected void appendTextToCurrentBranch(Tag tag, int textStart, int textLength) {
if (textLength > 0) {
Node node = createTextNode(tag, new String(doc, textStart, textLength));
node.getStyleClass().addAll(getStyleClass()); // inherit all styles from stack
node.setStyle(getStyle());
// if branch is empty, start a new paragraph from root
if (openBlocks.isEmpty()) {
appendTextToRoot(node);
} else {
openBlocks.getFirst().addText(node);
}
}
}
protected Node createTextNode(Tag tag, String text) {
return switch (tag.name()) {
case "label", "code" -> new Label(text);
case "url" -> {
var node = new Hyperlink(text);
node.setUserData(tag.getParam("url"));
node.getStyleClass().add("url");
yield node;
}
case "email" -> {
var node = new Hyperlink(text);
node.setUserData(tag.getParam("email"));
node.getStyleClass().add("email");
yield node;
}
case "abbr" -> {
var node = new Label(text);
String tooltip = tag.getParam("abbr");
if (tooltip != null) {
node.setTooltip(new Tooltip(tooltip));
}
yield node;
}
default -> {
var node = new Text(text);
node.getStyleClass().add(Styles.TEXT);
yield node;
}
};
}
protected void createBranch() {
final var tag = openTags.getFirst();
if (!tag.isBlock()) {
return;
}
final var hgap = 10;
final var vgap = 10;
boolean isGrid = false;
final Block parent = !openBlocks.isEmpty() ? openBlocks.getFirst() : root;
final Block current = switch (tag.name()) {
case "ul" -> {
var pane = new VBox(vgap);
pane.setUserData(tag.getParam("ul", ""));
pane.getStyleClass().addAll("ul", "list");
yield new Block(pane, null);
}
case "ol" -> {
var grid = new GridPane();
grid.setVgap(vgap);
grid.setHgap(hgap);
grid.setUserData(getListStartNumber(tag.getParam("ol")));
grid.getStyleClass().addAll("ol", "list");
grid.getColumnConstraints().addAll(
new ColumnConstraints(
Region.USE_PREF_SIZE, -1, Region.USE_PREF_SIZE, Priority.NEVER, HPos.LEFT, false
),
new ColumnConstraints(
-1, -1, -1, Priority.SOMETIMES, HPos.LEFT, false
)
);
yield new Block(grid, null);
}
case "li" -> {
var text = new TextFlow();
if (parent.node().getStyleClass().contains("ol") && parent.node() instanceof GridPane grid) {
isGrid = true;
// use Label instead of Text because of better vertical alignment
grid.addRow(grid.getRowCount(), new Label(getListItemNumber(grid)), text);
grid.getRowConstraints().add(
new RowConstraints(
-1, -1, -1, Priority.NEVER, VPos.TOP, false
)
);
yield new Block(text, text);
} else if (parent.node().getStyleClass().contains("ul")) {
var bullet = String.valueOf(parent.node().getUserData());
HBox pane = new HBox(hgap, new Text(bullet), text);
pane.setAlignment(Pos.BASELINE_LEFT);
pane.getStyleClass().add("li");
yield new Block(pane, text);
} else {
throw new UnsupportedOperationException("Invalid parent tag: 'ul' or 'ol' required.");
}
}
case "align" -> {
var text = new TextFlow();
var pane = new HBox(text);
pane.setAlignment(getEnumValue(Pos.class, tag.getParam("align"), Pos.TOP_LEFT));
yield new Block(pane, text);
}
case "left" -> {
var text = new TextFlow();
text.setTextAlignment(TextAlignment.LEFT);
var pane = new HBox(text);
pane.setAlignment(Pos.TOP_LEFT);
yield new Block(pane, text);
}
case "center" -> {
var text = new TextFlow();
text.setTextAlignment(TextAlignment.CENTER);
var pane = new HBox(text);
pane.setAlignment(Pos.TOP_CENTER);
yield new Block(pane, text);
}
case "right" -> {
var text = new TextFlow();
text.setTextAlignment(TextAlignment.RIGHT);
var pane = new HBox(text);
pane.setAlignment(Pos.TOP_RIGHT);
yield new Block(pane, text);
}
case "indent" -> {
var text = new TextFlow();
var pane = new HBox(text);
var indent = parseIntOrDefault(tag.getParam("indent"), 1);
pane.setPadding(new Insets(0, 0, 0, indent * 10));
pane.getStyleClass().add("indent");
yield new Block(pane, text);
}
default -> new Block(new VBox(), null);
};
// inherit all styles from stack
current.node().getStyleClass().addAll(getStyleClass());
var currentStyle = current.node().getStyle();
var newStyle = getStyle();
current.node().setStyle(
currentStyle != null && !currentStyle.isEmpty() ? currentStyle + ";" + newStyle : getStyle()
);
if (!isGrid) { // grid requires col and row number
parent.children().add(current.node());
}
openBlocks.push(current);
}
protected void appendSelfCloseTag(Tag tag) {
final Block parent = !openBlocks.isEmpty() ? openBlocks.getFirst() : root;
Node node = null;
if ("hr".equals(tag.name())) {
node = new HBox();
node.getStyleClass().add("hr");
var thickness = parseIntOrDefault(tag.getParam("hr"), 1);
node.setStyle(String.format("-fx-border-width:0 0 %d 0", thickness));
}
if (node != null) {
parent.children().add(node);
}
}
protected void addStyleIfPresent(Map<String, String> params, String name, String key, Collection<String> c) {
if (params != null && params.containsKey(key)) {
c.add(name + ":" + params.get(key));
}
}
protected void addStyleIfPresent(Map<String, String> params, String key, Collection<String> c, String sep) {
if (params != null && params.containsKey(key)) {
Collections.addAll(c, params.get(key).split(sep));
}
}
protected <E extends Enum<E>> E getEnumValue(Class<E> c, @Nullable String value, E defaultValue) {
if (value == null) {
return defaultValue;
}
try {
return Enum.valueOf(c, value.toUpperCase());
} catch (Exception ignore) {
return defaultValue;
}
}
protected int getListStartNumber(@Nullable String param) {
int start = 1;
if (param == null) {
return start;
}
try {
start = Integer.parseInt(param);
} catch (Exception ignore) {
if (param.length() == 1 && Character.isLetter(param.charAt(0))) {
start = OL_LETTER_OFFSET + param.charAt(0);
}
}
return start;
}
protected int parseIntOrDefault(@Nullable String s, int defaultValue) {
if (s == null) {
return defaultValue;
}
try {
return Integer.parseInt(s);
} catch (Exception ignore) {
return defaultValue;
}
}
protected String getParamOrDefault(Map<String, String> params, String key, String defaultValue) {
if (params != null) {
return params.getOrDefault(key, defaultValue);
} else {
return defaultValue;
}
}
protected String getListItemNumber(GridPane grid) {
int start = (int) Objects.requireNonNullElse(grid.getUserData(), 1);
return start < OL_LETTER_OFFSET
? (start + grid.getRowCount()) + "." // number
: (char) (start + grid.getRowCount() - OL_LETTER_OFFSET) + "."; // letter
}
protected Set<String> getStyleClass() {
// set keys cannot be overwritten,
// if parent contains the same style class, child wins
Iterator<Tag> it = openTags.descendingIterator();
Set<String> styleClass = new LinkedHashSet<>();
while (it.hasNext()) {
var tag = it.next();
styleClass.addAll(tag.styleClasses());
}
return styleClass;
}
protected String getStyle() {
// set keys cannot be overwritten,
// if parent contains the same style rule, child wins
Iterator<Tag> it = openTags.descendingIterator();
Set<String> style = new LinkedHashSet<>();
while (it.hasNext()) {
var tag = it.next();
style.addAll(tag.styles());
}
return String.join(";", style);
}
}
/**
* Generic block record.
*
* @param node the node that represents the block
* @param text text content
*/
record Block(Pane node, @Nullable TextFlow text) {
public Block {
Objects.requireNonNull(node);
}
public void addText(Node child) {
// Considering that markup can be generated at runtime the policy
// is to silently ignore errors where possible. This can lead to
// misaligned blocks or missed text at worst.
if (canContainText()) {
text.getChildren().add(child);
}
}
public List<Node> children() {
return node.getChildren();
}
public int size() {
return node.getChildren().size();
}
public boolean isEmpty() {
return node.getChildren().isEmpty();
}
public boolean canContainText() {
return text != null;
}
}
/**
* Generic tag record.
*
* @param name tag name
* @param params tag params
* @param styleClasses CSS classes, each element is either a single style or space delimited string
* @param styles CSS styles, each element is either a single style or semicolon delimited string
*/
record Tag(String name,
Type type,
@Nullable Map<String, String> params,
Set<String> styleClasses,
Set<String> styles) {
public enum Type {
BLOCK, TEXT, SELF_CLOSE
}
public Tag {
Objects.requireNonNull(name);
Objects.requireNonNull(type);
params = Objects.requireNonNullElse(params, Collections.emptyMap());
styleClasses = Objects.requireNonNullElse(styleClasses, Collections.emptySet());
styles = Objects.requireNonNullElse(styles, Collections.emptySet());
}
public boolean hasParam(String name) {
return params != null && params.containsKey(name);
}
public String getParam(String name) {
return params != null ? params.get(name) : null;
}
public String getParam(String name, String defaultValue) {
return params != null ? params.getOrDefault(name, defaultValue) : defaultValue;
}
public boolean isBlock() {
return type == Type.BLOCK;
}
public boolean isSelfClose() {
return type == Type.SELF_CLOSE;
}
}
}

@ -0,0 +1,298 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.base.util;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javafx.geometry.Pos;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import org.jetbrains.annotations.Nullable;
/**
* A simple push parser for the <a href="https://www.bbcode.org/">BBCode</a> markup.
* As the content is parsed, methods of {@link BBCodeHandler} are called.<br/><br/>
*
* <p>The parser doesn't impose restrictions on tag names or tag params.
* It's a handler implementation responsibility to differentiate supported
* tags from unsupported and so to for tag params. This allows user to utilize
* arbitrary tags or params without changing the parser behaviour. The parser,
* however, verifies that each opening tag has the matching closing tag.
* If parsing is failed due to invalid input an {@link IllegalStateException}
* will be thrown.
*/
public class BBCodeParser {
/**
* Reserved tag names. Note, this isn't the list of supported codes
* as the {@link BBCodeHandler} is responsible for implementation.
*/
public static final Set<String> RESERVED_TAGS = Set.of(
// supported by the default handler
"abbr", "align", "b", "caption", "center", "code", "color", "email", "heading",
"font", "hr", "i", "indent", "label", "left", "li", "ol",
"right", "s", "size", "small", "span", "style", "sub", "sup", "u", "ul", "url",
// reserved
"alert", "em", "fieldset", "h1", "h2", "h3", "h4", "icon", "img", "info", "kbd",
"list", "media", "plain", "pre", "quote", "spoiler", "stop", "table", "tooltip",
"td", "th", "tr", "warning"
);
private final String input;
private final BBCodeHandler handler;
private final Set<String> processedTags;
private final Deque<String> openTags = new ArrayDeque<>();
private int offset = 0;
private int lastClosingPos = 0;
/**
* See {@link #BBCodeParser(String, BBCodeHandler, Set)}.
*/
public BBCodeParser(String input, BBCodeHandler handler) {
this(input, handler, RESERVED_TAGS);
}
/**
* Creates a new parser.
*
* @param input an input non-null string
* @param handler a {@link BBCodeHandler} implementation
* @param tags the list of processed tags, i.e. the tags that parser won't ignore
*/
public BBCodeParser(String input, BBCodeHandler handler, @Nullable Set<String> tags) {
this.input = Objects.requireNonNull(input, "Input can't be null.");
this.handler = Objects.requireNonNull(handler, "Handler can't be null.");
this.processedTags = Objects.requireNonNullElse(tags, RESERVED_TAGS);
}
/**
* Starts input parsing. There's no way to stop the process until
* parsing is finished.
*/
public void parse() {
handler.startDocument(input.toCharArray());
while (offset < input.length()) {
if (input.charAt(offset) == '[') {
int openBracketPos = offset;
int closeBracketPos = input.indexOf(']', offset);
// a single square bracket, isn't a part of the markup
if (closeBracketPos == -1) {
offset++;
continue;
}
int tagLength = closeBracketPos - openBracketPos + 1;
// empty brackets, isn't a part of the markup
if (tagLength == 2) {
offset++;
continue;
}
if (input.charAt(openBracketPos + 1) != '/') {
// push leading and intermediate characters
if (openTags.isEmpty()) {
handleCharacters(
lastClosingPos > 0 ? lastClosingPos + 1 : 0,
lastClosingPos > 0 ? offset - lastClosingPos - 1 : offset
);
}
boolean selfClose = input.charAt(closeBracketPos - 1) == '/';
var isKnownTag = handleStartTag(openBracketPos, tagLength, selfClose);
// an unknown "opened tag", and we are not inside opened known tag
if (!isKnownTag && openTags.isEmpty()) {
handleCharacters(openBracketPos, tagLength);
offset += tagLength;
lastClosingPos = closeBracketPos + 1;
continue;
}
// move input cursor immediately, because self-close tags can't contain text
if (selfClose) {
lastClosingPos = closeBracketPos;
}
} else {
var isKnownTag = handleEndTag(openBracketPos, tagLength);
// an unknown "closing tag", and we are not inside opened known tag
if (!isKnownTag && openTags.isEmpty()) {
handleCharacters(lastClosingPos, closeBracketPos - lastClosingPos + 1);
}
lastClosingPos = closeBracketPos;
}
offset = closeBracketPos + 1;
} else {
// increment offset for any other character
offset++;
}
}
if (!openTags.isEmpty()) {
throw new IllegalStateException("Invalid BBCode: Opening tags without closing tags: " + openTags);
}
// push trailing characters
if (lastClosingPos < input.length()) {
handleCharacters(
lastClosingPos > 0 ? lastClosingPos + 1 : lastClosingPos,
lastClosingPos > 0 ? input.length() - lastClosingPos - 1 : input.length()
);
}
handler.endDocument();
}
/**
* See {@link #createLayout(String, Pane)}.
*/
public static TextFlow createFormattedText(String input) {
return createLayout(input, new TextFlow());
}
/**
* See {@link #createLayout(String, Pane)}.
*/
public static VBox createLayout(String input) {
var b = new VBox(10);
b.setAlignment(Pos.TOP_LEFT);
return createLayout(input, b);
}
/**
* Parses the given string using BBCode markup and returns corresponding layout.
* This is a shorthand method for using the feature.
*
* @param input BBCode markup string
* @param container root container
*/
public static <T extends Pane> T createLayout(String input, T container) {
var handler = new BBCodeHandler.Default<>(container);
var parser = new BBCodeParser(input, handler);
parser.parse();
return container;
}
///////////////////////////////////////////////////////////////////////////
protected boolean handleStartTag(int start, int length, boolean selfClose) {
List<String> tokens = splitBySpace(input, start + 1, !selfClose ? length - 1 : length - 2);
// only the name and param names are case-insensitive, param values aren't
String name = tokens.get(0).toLowerCase();
Map<String, String> params = null;
for (int i = 0; i < tokens.size(); i++) {
var token = tokens.get(i);
int separatorPos = token.indexOf("=");
if (separatorPos >= 0) {
String key = token.substring(0, separatorPos).toLowerCase();
String value = token.substring(separatorPos + 1);
if (params == null) {
params = new HashMap<>();
}
// some bb codes use the format "[name=value]text[/name]",
// in that case params map should have just a single key
// which is exactly the same as the tag name
if (i == 0) {
name = key;
}
params.put(key, value);
}
}
if (!processedTags.contains(name)) {
return false;
}
handler.startTag(name, params, start, length);
if (!selfClose) {
openTags.push(name);
}
return true;
}
protected boolean handleEndTag(int start, int length) {
// ignore case
String name = input.substring(start + 2, start + length - 1).toLowerCase();
if (!processedTags.contains(name)) {
return false;
}
if (openTags.isEmpty()) {
throw new IllegalStateException(
"Invalid BBCode: Closing tag without corresponding opening tag: '" + name + "'"
);
}
String lastTag = openTags.pop();
if (!lastTag.equals(name)) {
throw new IllegalStateException(
"Invalid BBCode: Closing tag '" + name + "' does not match opening tag '" + lastTag + "'"
);
}
handler.endTag(name, start, length);
return true;
}
protected void handleCharacters(int start, int length) {
handler.characters(start, length);
}
/**
* Splits input string by whitespace ignoring quoted text. E.g.
* <pre>
* "foo bar" = ["foo", "bar"]
* "foo 'bar baz'" = ["foo", "bar baz"]
* </pre>
*/
protected List<String> splitBySpace(String str, int start, int length) {
var tokens = new ArrayList<String>();
var sb = new StringBuilder();
boolean insideQuotes = false;
for (int i = start; i < start + length - 1; i++) {
char ch = str.charAt(i);
if (ch == ' ' && !insideQuotes) {
tokens.add(sb.toString());
sb = new StringBuilder();
} else {
if (ch == '"' || ch == '\'') {
insideQuotes = !insideQuotes;
} else {
// remove quotes from param value,
// works for [name="value"] format as well
sb.append(ch);
}
}
}
tokens.add(sb.toString());
return tokens;
}
}

@ -0,0 +1,325 @@
package atlantafx.base.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
public class BBCodeParserTest {
@Test
public void testEmptyString() {
var handler = BBCodeMockHandler.testString("");
assertThat(handler.tags()).isEmpty();
assertThat(handler.text()).isEmpty();
}
@Test
public void testSingleBlankString() {
var handler = BBCodeMockHandler.testString(" ");
assertThat(handler.tags()).isEmpty();
assertThat(handler.text()).containsExactlyInAnyOrder(" ");
}
@Test
public void testNoMarkupString() {
var handler = BBCodeMockHandler.testString("This_is_a_bold_text");
assertThat(handler.tags()).isEmpty();
assertThat(handler.text()).containsExactlyInAnyOrder("This_is_a_bold_text");
}
@Test
public void testEmptyTag() {
var handler = BBCodeMockHandler.testString("[b][/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, ""));
assertThat(handler.text()).isEmpty();
}
@Test
public void testUnknownTags() {
var handler = BBCodeMockHandler.testString("This_is_[foo]a_bold[/bar]_text");
assertThat(handler.tags()).isEmpty();
assertThat(String.join("", handler.text())).isEqualTo("This_is_[foo]a_bold[/bar]_text");
handler = BBCodeMockHandler.testString("[foo]This_is_a_bold_text[/bar]");
assertThat(handler.tags()).isEmpty();
assertThat(String.join("", handler.text())).isEqualTo("[foo]This_is_a_bold_text[/bar]");
handler = BBCodeMockHandler.testString("[/foo]This_is_a_bold_text[bar]");
assertThat(handler.tags()).isEmpty();
assertThat(String.join("", handler.text())).isEqualTo("[/foo]This_is_a_bold_text[bar]");
handler = BBCodeMockHandler.testString("[/foo]This_is_a_[b]bold[/b]_text[bar]");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "bold"));
assertThat(String.join("", handler.text())).isEqualTo("[/foo]This_is_a__text[bar]");
handler = BBCodeMockHandler.testString("[b]This_is_a_[foo]bold[/bar]_text[/b]");
assertThat(handler.tags()).containsExactly(
new MockTag("b", null, "This_is_a_[foo]bold[/bar]_text")
);
assertThat(handler.text()).isEmpty();
}
@Test
public void testSingleTag() {
var handler = BBCodeMockHandler.testString("[b]text[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "text"));
assertThat(handler.text()).isEmpty();
}
@Test
public void testLeadingTag() {
var handler = BBCodeMockHandler.testString("[b]This[/b]_is_a_bold_text");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "This"));
assertThat(handler.text()).containsExactly("_is_a_bold_text");
}
@Test
public void testIntermediateTag() {
var handler = BBCodeMockHandler.testString("This_is_a_[b]bold[/b]_text");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "bold"));
assertThat(handler.text()).containsExactly("This_is_a_", "_text");
}
@Test
public void testTrailingTag() {
var handler = BBCodeMockHandler.testString("This_is_a_bold_[b]text[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", null, "text"));
assertThat(handler.text()).containsExactly("This_is_a_bold_");
}
@Test
public void testShorthandParamSyntax() {
var handler = BBCodeMockHandler.testString("[b=foo]bold[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("b", "foo"), "bold"));
assertThat(handler.text()).isEmpty();
}
@Test
public void testShorthandParamSyntaxWithQuotes() {
var handler = BBCodeMockHandler.testString("[b=\"foo bar\"]bold[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("b", "foo bar"), "bold"));
assertThat(handler.text()).isEmpty();
}
@Test
public void testSingleTagParam() {
var handler = BBCodeMockHandler.testString("[b param=foo]bold[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("param", "foo"), "bold"));
assertThat(handler.text()).isEmpty();
}
@Test
public void testSingleTagParamWithQuotes() {
var handler = BBCodeMockHandler.testString("[b param='foo bar']bold[/b]");
assertThat(handler.tags()).containsExactly(new MockTag("b", Map.of("param", "foo bar"), "bold"));
assertThat(handler.text()).isEmpty();
}
@Test
public void testMultipleTagParams() {
var handler = BBCodeMockHandler.testString("[b param1=foo param2=bar param3=baz]bold[/b]");
assertThat(handler.tags()).containsExactlyInAnyOrder(new MockTag(
"b", Map.of("param1", "foo", "param2", "bar", "param3", "baz"), "bold")
);
assertThat(handler.text()).isEmpty();
}
@Test
public void testMultipleTagParamsWithQuotes() {
var handler = BBCodeMockHandler.testString("[b param1=foo param2=\"a b\" param3='c d']bold[/b]");
assertThat(handler.tags()).containsExactlyInAnyOrder(new MockTag(
"b", Map.of("param1", "foo", "param2", "a b", "param3", "c d"), "bold")
);
assertThat(handler.text()).isEmpty();
}
@Test
public void testSiblingTags() {
var handler = BBCodeMockHandler.testString("This_[i]is[/i]_a_[b]bold[/b]_[s]text[/s]");
assertThat(handler.tags()).containsExactly(
new MockTag("i", null, "is"),
new MockTag("b", null, "bold"),
new MockTag("s", null, "text")
);
assertThat(handler.text()).containsExactly("This_", "_a_", "_");
}
@Test
public void testNestedTags() {
var handler = BBCodeMockHandler.testString("This_[i][s]is_[b]a[/b]_bold[/s]_text[/i]");
assertThat(handler.text()).containsExactly("This_");
assertThat(handler.tags()).containsExactly(
new MockTag("b", null, "a"),
new MockTag("s", null, "is_[b]a[/b]_bold"),
new MockTag("i", null, "[s]is_[b]a[/b]_bold[/s]_text")
);
}
@Test
public void testEqualNestedTags() {
var handler = BBCodeMockHandler.testString("This_[b]is_[b]a[/b]_bold[/b]_text");
assertThat(handler.text()).containsExactly("This_", "_text");
assertThat(handler.tags()).containsExactly(
new MockTag("b", null, "a"),
new MockTag("b", null, "is_[b]a[/b]_bold")
);
}
@Test
public void testNestedTagsWithParams() {
var handler = BBCodeMockHandler.testString("This_[i=foo][s]is_[b bar=baz]a[/b]_bold[/s]_text[/i]");
assertThat(handler.text()).containsExactly("This_");
assertThat(handler.tags()).containsExactly(
new MockTag("b", Map.of("bar", "baz"), "a"),
new MockTag("s", null, "is_[b bar=baz]a[/b]_bold"),
new MockTag("i", Map.of("i", "foo"), "[s]is_[b bar=baz]a[/b]_bold[/s]_text")
);
}
@Test
public void testUnclosedTagThrowsException() {
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_bold_text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testNestedUnclosedTagThrowsException() {
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_[i]bold_[/b]text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testUnopenedTagThrowsException() {
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[/b]a_bold_text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testNestedUnopenedTagThrowsException() {
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_[/i]bold_[/b]text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testNotMatchingClosingTagThrowsException() {
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[b]a_bold_[/i]text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testTagInsideDoubleBracesThrowsException() {
// this is known and won't be fixed
assertThatThrownBy(() -> BBCodeMockHandler.testString("This_is_[[url=link]a_bold[/url]]_text"))
.isInstanceOf(IllegalStateException.class);
}
@Test
public void testSelfCloseTag() {
var handler = BBCodeMockHandler.testString("This_[hr/]is_a_[hr=5/]bold_text");
assertThat(handler.tags()).containsExactly(
new MockTag("hr", null, null),
new MockTag("hr", Map.of("hr", "5"), null)
);
assertThat(handler.text()).containsExactlyInAnyOrder("This_", "is_a_", "bold_text");
}
///////////////////////////////////////////////////////////////////////////
public record MockTag(String name,
@Nullable Map<String, String> params,
@Nullable String text) {
}
public static class BBCodeMockHandler implements BBCodeHandler {
private static final Set<String> SELF_CLOSE_TAGS = Set.of("hr", "img");
private static final boolean DEBUG = false;
private static final Map<String, String> PARAMS_PLACEHOLDER = new HashMap<>();
private final List<MockTag> tags = new ArrayList<>();
private final List<String> text = new ArrayList<>();
private final Deque<Integer> textStart = new ArrayDeque<>();
private final Deque<Map<String, String>> tagParams = new ArrayDeque<>();
private char[] doc;
@Override
public void startDocument(char[] doc) {
this.doc = doc;
}
@Override
public void endDocument() {
this.doc = null;
}
@Override
public void startTag(String name, @Nullable Map<String, String> params, int start, int length) {
debug("START:" + name + "|" + params + "|" + start + "|" + length + "|" + new String(doc, start, length));
if (!SELF_CLOSE_TAGS.contains(name)) {
tagParams.push(params != null ? params : PARAMS_PLACEHOLDER);
textStart.push(start + length);
} else {
tags.add(new MockTag(name, params, null));
textStart.push(start + length);
}
}
@Override
public void endTag(String name, int start, int length) {
debug("END:" + name + "|" + start + "|" + length + "|" + new String(doc, start, length));
var params = tagParams.pop();
var openTagTextStart = textStart.pop();
tags.add(new MockTag(
name,
params != PARAMS_PLACEHOLDER ? params : null,
new String(doc, openTagTextStart, start - openTagTextStart))
);
}
@Override
public void characters(int start, int length) {
debug("CHARACTERS:" + new String(doc, start, length));
if (length > 0) {
text.add(new String(doc, start, length));
}
}
private void debug(String s) {
if (DEBUG) {
System.out.println(s);
}
}
public List<MockTag> tags() {
return tags;
}
public List<String> text() {
return text;
}
public @Nullable MockTag get(String name) {
return tags.stream()
.filter(tag -> name.equals(tag.name()))
.findFirst()
.orElse(null);
}
public static BBCodeMockHandler testString(String input) {
var handler = new BBCodeMockHandler();
var parser = new BBCodeParser(input, handler);
parser.parse();
return handler;
}
}
}

@ -7,6 +7,7 @@ import static atlantafx.sampler.layout.MainModel.SubLayer.SOURCE_CODE;
import atlantafx.sampler.page.Page;
import atlantafx.sampler.page.components.AccordionPage;
import atlantafx.sampler.page.components.BBCodePage;
import atlantafx.sampler.page.components.BreadcrumbsPage;
import atlantafx.sampler.page.components.ButtonPage;
import atlantafx.sampler.page.components.ChartPage;
@ -186,6 +187,7 @@ public class MainModel {
var extras = NavTree.Item.group("Extras", new FontIcon(Material2OutlinedMZ.TOGGLE_ON));
extras.getChildren().setAll(
NAV_TREE.get(InputGroupPage.class),
NAV_TREE.get(BBCodePage.class),
NAV_TREE.get(BreadcrumbsPage.class),
NAV_TREE.get(CustomTextFieldPage.class),
NAV_TREE.get(PopoverPage.class),
@ -221,6 +223,7 @@ public class MainModel {
map.put(AccordionPage.class, NavTree.Item.page(AccordionPage.NAME, AccordionPage.class));
map.put(BreadcrumbsPage.class, NavTree.Item.page(BreadcrumbsPage.NAME, BreadcrumbsPage.class));
map.put(ButtonPage.class, NavTree.Item.page(ButtonPage.NAME, ButtonPage.class));
map.put(BBCodePage.class, NavTree.Item.page(BBCodePage.NAME, BBCodePage.class));
map.put(ChartPage.class, NavTree.Item.page(ChartPage.NAME, ChartPage.class));
map.put(CheckBoxPage.class, NavTree.Item.page(CheckBoxPage.NAME, CheckBoxPage.class));
map.put(ColorPickerPage.class, NavTree.Item.page(ColorPickerPage.NAME, ColorPickerPage.class));
@ -267,9 +270,9 @@ public class MainModel {
map.put(TreeTablePage.class, NavTree.Item.page(TreeTablePage.NAME, TreeTablePage.class));
map.put(FileManagerPage.class, NavTree.Item.page(FileManagerPage.NAME, FileManagerPage.class));
map.put(MusicPlayerPage.class, NavTree.Item.page(FileManagerPage.NAME, MusicPlayerPage.class));
map.put(MusicPlayerPage.class, NavTree.Item.page(MusicPlayerPage.NAME, MusicPlayerPage.class));
map.put(WidgetCollectionPage.class, NavTree.Item.page(
FileManagerPage.NAME,
WidgetCollectionPage.NAME,
WidgetCollectionPage.class, "Card", "Message", "Stepper", "Tag")
);

@ -0,0 +1,276 @@
/* SPDX-License-Identifier: MIT */
package atlantafx.sampler.page.components;
import atlantafx.base.theme.Styles;
import atlantafx.base.util.BBCodeParser;
import atlantafx.sampler.page.AbstractPage;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
public class BBCodePage extends AbstractPage {
public static final String NAME = "BBCode Markup";
@Override
public String getName() {
return NAME;
}
public BBCodePage() {
super();
setUserContent(new VBox(20,
reference(),
article()
));
}
private VBox reference() {
var root = new VBox(20);
root.setAlignment(Pos.TOP_LEFT);
var header = BBCodeParser.createFormattedText("""
[left][heading=1]Reference[/heading][/left]\
BBCode ("Bulletin Board Code") is a lightweight markup language used to format messages \
in many Internet forum software. It was first introduced in 1998. The available tags of BBCode \
are usually indicated by square brackets surrounding a keyword, and are parsed before \
being translated into [s]HTML[/s] JavaFX layout.""");
root.getChildren().add(header);
ReferenceBlock block = new ReferenceBlock(
"Bold, italics, underline and strikethrough",
"Makes the wrapped text bold, italic, underlined, or strikethrough."
);
block.addFormattedText("This is [b]bold[/b] text.");
block.addFormattedText("This is [i]italic[/i] text.");
block.addFormattedText("This is [u]underlined[/u] text.");
block.addFormattedText("This is [s]strikethrough[/s] text.");
root.getChildren().add(block);
block = new ReferenceBlock(
"Text color, font and size",
"Changes the color, font, or size of the wrapped text."
);
block.addFormattedText("This is [color=red]red[/color] text.");
block.addFormattedText("This is [color=-color-accent-emphasis]accent[/color] text.");
block.addFormattedText("This is [label style='-fx-background-color: yellow']background[/label] color.");
block.addFormattedText("This is [font=monospace]monospaced[/font] font.");
block.addFormattedText("This is [small]small[/small] and [size=1.5em]big[/size] text.");
root.getChildren().add(block);
block = new ReferenceBlock(
"Subscript and superscript",
"A text that is set slightly below or above the normal line of type, respectively."
);
block.addLayout("log[sub][small]2[/small][/sub](256) = 8");
block.addLayout("10[sup][small]2[/small][/sup] = 100");
root.getChildren().add(block);
block = new ReferenceBlock(
"Headings levels 1 to 5",
"Marks text as a structured heading."
);
block.addFormattedText("""
[heading=1]H1 headline[/heading]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
block.addFormattedText("""
[heading=2]H2 headline[/heading]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
block.addFormattedText("""
[heading=3]H3 headline[/heading]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
block.addFormattedText("""
[heading=4]H4 headline[/heading]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
block.addFormattedText("""
[caption]Caption[/caption]
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non purus a nisi ornare facilisis.""");
root.getChildren().add(block);
block = new ReferenceBlock(
"Lists",
"Displays a bulleted or numbered list."
);
block.addLayout("""
[ul]
[li]Entry 1[/li]
[li]Entry 2[/li]
[/ul]""");
block.addLayout("""
[ul=🗹]
[li]Entry 1[/li]
[li]Entry 2[/li]
[/ul]""");
block.addLayout("""
[ol]
[li]Entry 1[/li]
[li]Entry 2[/li]
[/ol]""");
block.addLayout("""
[ol=10]
[li]Entry 1[/li]
[li]Entry 2[/li]
[/ol]""");
block.addLayout("""
[ol=a]
[li]Entry 1[/li]
[li]Entry 2[/li]
[/ol]""");
root.getChildren().add(block);
block = new ReferenceBlock(
"Linking",
"Links the wrapped text to the specified web page or email address."
);
block.addFormattedText("[url=https://www.example.com]Go to example.com[/url]");
block.addFormattedText("[email=johndoe@example.com]Email me[/email]");
root.getChildren().add(block);
block = new ReferenceBlock(
"Alignment",
"Changes the alignment of the wrapped text."
);
block.addLayout("[left]Left-aligned[/left]");
block.addLayout("[center]Center-aligned[/center]");
block.addLayout("[right]Right-aligned[/right]");
block.addLayout("[align=center]Center-aligned[/align]");
root.getChildren().add(block);
block = new ReferenceBlock(
"Text indent",
"Indents the wrapped text."
);
block.addLayout("[indent]Indented text[/indent]");
block.addLayout("[indent=3]More indented[/indent]");
root.getChildren().add(block);
block = new ReferenceBlock(
"Horizontal line",
"A horizontal separator line."
);
block.addLayout("Default line: [hr/]");
block.addLayout("Thick line: [hr=5/]");
root.getChildren().add(block);
block = new ReferenceBlock(
"Abbreviation",
"An abbreviation, with mouse-over expansion."
);
block.addLayout("[abbr='on hover text']text[/abbr]");
block.addLayout("[abbr]text[/abbr]");
root.getChildren().add(block);
return root;
}
private VBox article() {
var article = """
[left][heading=1]Example[/heading][/left]\
[b]JavaFX[/b] is a Java library used to build Rich Internet Applications. \
The applications written using this library can run consistently across multiple \
platforms. The applications developed using JavaFX can run on various devices \
such as Desktop Computers, Mobile Phones, TVs, Tablets, etc.
To develop GUI Applications using [color=-color-accent-emphasis]Java programming language[/color], \
the programmers rely on libraries such as [s]Advanced Windowing Toolkit[/s] and Swing. \
After the advent of JavaFX, these Java programmers can now develop \
[abbr='Graphical User Interface']GUI[/abbr] applications effectively with rich content.
[heading=3]Key Features[/heading]
Following are some of the [i]important features[/i] of JavaFX:\
[ul]
[li][b]Written in Java[/b] The JavaFX library is written in Java and is [u]available for \
the languages that can be executed on a JVM[/u], which include Java, \
[url="https://groovy-lang.org/"]Groovy[/url] and JRuby.These JavaFX applications are also \
platform independent.[/li]\
[li][b]FXML[/b] JavaFX features a language known as FXML, which is a HTML like declarative \
markup language. The sole purpose of this language is to define a user interface.[/li]\
[li][b]Scene Builder[/b] JavaFX provides an application named Scene Builder. On integrating \
this application in IDEs such [as Eclipse and NetBeans], the users can access a drag \
and drop design interface, which is used to develop [code]FXML[/code] applications (just like \
Swing Drag & Drop and DreamWeaver Applications).[/li]\
[li][b]Built-in UI controls[/b] JavaFX library caters UI controls using which we can develop a \
full-featured application.[/li]\
[li][b]CSS like Styling[/b] JavaFX provides a CSS like styling. By using this, you can improve \
the design of your application with a simple knowledge of CSS.[/li]\
[li][b]Canvas and Printing[/b] JavaFX provides [label style=-fx-background-color:yellow]Canvas[/label], \
an immediate mode style of rendering API. Within the package [font=monospace]javafx.scene.canvas[/font] \
it holds a set of classes for canvas, using which we can draw directly within an area of the \
JavaFX scene. JavaFX also provides classes for Printing purposes in the package javafx.print.[/li]\
[li][b]Graphics pipeline[/b] JavaFX supports graphics based on the hardware-accelerated graphics \
pipeline known as Prism. When used with a supported Graphic Card or GPU it offers smooth \
graphics. In case the system does not support graphic card then prism defaults to the \
software rendering stack.[/li]\
[/ul]\
[hr/]\
[right]Source: [url=https://www.tutorialspoint.com/javafx/javafx_overview.htm]tutorialspoint.com\
[/url][/right]""";
return BBCodeParser.createLayout(article);
}
///////////////////////////////////////////////////////////////////////////
private static class ReferenceBlock extends VBox {
private final VBox leftBox;
private final VBox rightBox;
public ReferenceBlock(String title, String description) {
super();
var titleLabel = new Label(title);
titleLabel.getStyleClass().add(Styles.TITLE_4);
leftBox = new VBox(15);
leftBox.prefWidthProperty().bind(widthProperty().divide(2));
leftBox.setPadding(new Insets(10));
leftBox.setStyle("""
-fx-font-family:monospace;\
-fx-background-color:-color-bg-subtle;\
-fx-border-width:1px;\
-fx-border-color:-color-border-default"""
);
rightBox = new VBox(15);
rightBox.prefWidthProperty().bind(widthProperty().divide(2));
rightBox.setPadding(new Insets(10));
rightBox.setStyle("""
-fx-background-color:-color-bg-subtle;\
-fx-border-width:1px;\
-fx-border-color:-color-border-default"""
);
var splitBox = new HBox(leftBox, rightBox);
splitBox.setSpacing(20);
setSpacing(10);
getChildren().addAll(titleLabel, new Label(description), splitBox);
}
public void addFormattedText(String markup) {
leftBox.getChildren().add(new TextFlow(new Text(markup)));
rightBox.getChildren().add(BBCodeParser.createFormattedText(markup));
}
public void addLayout(String markup) {
leftBox.getChildren().add(new TextFlow(new Text(markup)));
rightBox.getChildren().add(BBCodeParser.createLayout(markup));
}
}
}

@ -14,6 +14,41 @@ $radius in cfg.$elevation {
@include effects.shadow(cfg.$elevation-color, cfg.$elevation-interactive);
}
///////////////////////////////////////////////////////////////////////////////
// BBCode //
///////////////////////////////////////////////////////////////////////////////
.bb-code {
.sub {
-fx-translate-y: 0.3em;
}
.sup {
-fx-translate-y: -0.3em;
}
.hr {
-fx-border-color: -color-border-default;
-fx-border-width: 0 0 1 0;
-fx-border-style: solid;
-fx-border-insets: 10px 0 10px 0
}
.code {
-fx-font-family: monospace;
-fx-border-color: -color-border-default;
-fx-border-width: 1;
-fx-background-color: -color-bg-subtle;
-fx-padding: 0 3 0 3;
}
.abbr {
-fx-border-color: -color-fg-default;
-fx-border-width: 0 0 1 0;
-fx-border-style: dashed;
}
}
///////////////////////////////////////////////////////////////////////////////
// Ikonli //
///////////////////////////////////////////////////////////////////////////////