Целые экраны
Экраны
Это не фрагменты и не макеты. Каждое из этих окон тулкит отрисовал, пока собирался этот сайт.
Демонстрационное приложение
Экран заказов — таблица с сортировкой и фильтром, каждый заказ открывается в панели, выезжающей сбоку, — и рядом все компоненты по категориям. Четыре переключателя вверху меняют язык, направление, размер и тему всего окна.
jbang demo-toolkit/limn-toolkitjbang --java-options=-XstartOnFirstThread demo-toolkit/limn-toolkit

То же окно, справа налево
Демонстрационное приложение с переводом ar и перевёрнутой осью направления: строка меню, навигация, поля, полосы прокрутки и таблица читаются справа, а текст в них соединяется и переупорядочивается, как того требует арабский. Ни один экран не переписан — направление это одна наследуемая ось, которую задают так же, как размер контролов.


Форма
Подписи, проверяемое поле, выбор, переключатель и ряд действий: разобранный пример из руководства по формам.


Код
public static Scene scene() {
Row centred = new Row();
centred.mainAlignment(Flex.MainAlignment.CENTER)
.crossAlignment(Flex.CrossAlignment.CENTER);
centred.add(new SizedBox(420, SizedBox.UNSET, form()));
Scene scene = new Scene(new Padding(Insets.all(32), centred));
return scene;
}
public static Widget<?> form() {
TextField name = new TextField();
name.setText("Ada Lovelace");
TextField email = new TextField();
email.setText("ada@example");
Field emailField = emailField(email);
validate(email, emailField);
// setText above was the application's write, and a handler runs for the user alone, so
// the rule in validate did not run: the starting state is set here, explicitly.
emailField.setValidation(Validation.ERROR, "Enter an address like ada@example.com");
PasswordField password = new PasswordField();
password.setText("correct horse battery");
ComboBox<String> plan = new ComboBox<>(List.of("Personal", "Team", "Enterprise"));
plan.setSelectedIndex(1);
Checkbox updates = new Checkbox(Checkbox.Variant.SWITCH, "Email me release notes");
updates.setChecked(true);
Column form = new Column();
form.gap(18).crossAlignment(Flex.CrossAlignment.STRETCH);
form.add(new Label("Create your account").setRole(Label.Role.TITLE));
form.add(counted(new Field("Full name", name).setRequired(true), name, 40));
form.add(emailField);
form.add(new Field("Password", password).setHelperText("At least 12 characters"));
form.add(new Field("Plan", plan));
form.add(updates);
form.add(Separator.horizontal());
form.add(actions());
return form;
}
static Field emailField(TextField email) {
return new Field("Email", email)
.setHelperText("The receipt goes here")
.setRequired(true);
}
static Field counted(Field field, TextField text, int limit) {
field.setHelperText(text.text().length() + " / " + limit);
text.observeChanges((widget, change) -> {
if (change.aspect() == Change.Aspect.TEXT) {
field.setHelperText(text.text().length() + " / " + limit);
}
});
return field;
}
static void validate(TextField email, Field field) {
email.onChange(text -> {
if (text.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) {
field.setValidation(Validation.SUCCESS);
} else {
field.setValidation(Validation.ERROR, "Enter an address like ada@example.com");
}
});
}
static Widget<?> actions() {
Row actions = new Row();
actions.gap(12).crossAlignment(Flex.CrossAlignment.CENTER);
actions.add(Expanded.spacer(1));
actions.add(new Button("Cancel").setStyle(Button.Style.SECONDARY));
actions.add(new Button("Create account"));
return actions;
}Скомпонованное окно
Панель инструментов, боковая панель рядом с областью содержимого и строка состояния: разобранный пример из руководства по компоновке.


Код
public static Scene scene() {
Scene scene = new Scene(shell());
return scene;
}
public static Widget<?> shell() {
ToolBar bar = new ToolBar();
bar.addItem(new Button("Import"));
bar.addSeparator();
bar.addItem(new Button("New collection").setStyle(Button.Style.SECONDARY));
bar.addItem(new Button("Share").setStyle(Button.Style.SECONDARY));
SplitPane panes = SplitPane.horizontal(sidebar(), library());
panes.setRatio(0.24f).setMinimums(150, 320);
Row status = spread(
new Label("18 assets · 3 selected").setMuted(true),
new Label("Synced 2 minutes ago").setMuted(true));
return column(0,
bar,
Expanded.of(panes, 1),
Separator.horizontal(),
new Padding(Insets.symmetric(8, 14), status));
}
private static Widget<?> sidebar() {
return column(0,
Expanded.of(collections(), 1),
Separator.horizontal(),
new Padding(Insets.all(14), details("Textures", "Studio drive")));
}
private static Widget<?> collections() {
ListView<String> list = new ListView<>(
name -> new Padding(Insets.symmetric(9, 14), new Label(name)));
list.setItems(COLLECTIONS);
list.setSelectedIndex(1);
// A list with no caption takes its name directly; a reader says "Collections, list".
list.setAccessibleName("Collections");
return list;
}
private static Widget<?> library() {
SearchField search = new SearchField();
SegmentedControl view = new SegmentedControl(List.of("Grid", "List"));
view.setSelectedIndex(0);
view.setAccessibleName("View");
Row tools = new Row();
tools.gap(10).crossAlignment(Flex.CrossAlignment.CENTER);
tools.add(new SizedBox(190, SizedBox.UNSET, search));
tools.add(view);
List<Widget<?>> tiles = new ArrayList<>();
for (String[] asset : ASSETS) {
tiles.add(tile(asset));
}
return new Padding(Insets.all(20), column(18,
spread(new Label("Textures").setRole(Label.Role.TITLE), tools),
tags(TAGS),
Expanded.of(new ScrollView(tiles(3, tiles)), 1)));
}
private static Widget<?> tile(String[] asset) {
Label badge = new Label(asset[1]);
badge.setColor(Color.WHITE).setStrong(true);
return column(6,
overlay(swatch(asset[3], asset[4]), badge),
new Label(asset[0]).setStrong(true),
new Label(asset[2]).setMuted(true));
}
private static Widget<?> swatch(String from, String to) {
int width = 96;
int height = 64;
Color a = Color.fromHex(from);
Color b = Color.fromHex(to);
byte[] pixels = new byte[width * height * 4];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
float t = (x / (float) (width - 1) + y / (float) (height - 1)) / 2f;
int i = (y * width + x) * 4;
Color mixed = a.lerp(b, t);
pixels[i] = (byte) Color.toByte(mixed.r());
pixels[i + 1] = (byte) Color.toByte(mixed.g());
pixels[i + 2] = (byte) Color.toByte(mixed.b());
pixels[i + 3] = (byte) 0xFF;
}
}
return new ImageView(new Image(width, height, pixels))
.setFit(ImageView.Fit.COVER)
.setPreferredSize(600, 86);
}
private static final List<String> COLLECTIONS =
List.of("All assets", "Textures", "Models", "HDRIs", "Fonts", "Archive");
/** The library's filters, as many and as long as a real collection's would be. */
private static final List<String> TAGS = List.of("Tileable", "PBR", "4K", "Outdoor",
"Brushed metal", "Wood", "Stone", "Organic", "Seamless", "Hand-painted", "Sci-fi panels");
/** name · overlay badge · size on disk · the two ends of the swatch's ramp. */
private static final String[][] ASSETS = {
{"Concrete 04", "2048 × 2048", "4.2 MB", "1E1230", "6D00E0"},
{"Brushed steel", "1024 × 1024", "1.8 MB", "10131E", "5A6E8C"},
{"Oak planks", "2048 × 2048", "3.6 MB", "241206", "9A5A22"},
{"Sand dunes", "4096 × 4096", "18.4 MB", "2A1A08", "C79A4B"},
{"Rust patina", "2048 × 2048", "5.1 MB", "2B0D08", "A8442A"},
{"Marble tile", "2048 × 2048", "3.9 MB", "141626", "8E93C8"},
{"Moss bank", "2048 × 2048", "4.7 MB", "0C1A0E", "3F8A46"},
// Eight, not nine: the last line of the grid is deliberately short, so the
// picture on the guide page shows what a short line does to the tile width.
{"Sea ice", "4096 × 4096", "21.0 MB", "081A22", "6FBFD6"},
};
static Column column(float gap, Widget<?>... children) {
Column column = new Column();
column.gap(gap).crossAlignment(Flex.CrossAlignment.STRETCH);
for (Widget<?> child : children) {
column.add(child);
}
return column;
}
static Row spread(Widget<?> leading, Widget<?> trailing) {
Row row = new Row();
row.gap(12).crossAlignment(Flex.CrossAlignment.CENTER);
row.add(leading);
row.add(Expanded.spacer(1));
row.add(trailing);
return row;
}
static Widget<?> overlay(Widget<?> content, Widget<?> on) {
Stack stack = new Stack();
stack.alignment(Stack.Alignment.CENTER);
stack.add(content);
stack.add(on);
return stack;
}
static Wrap tags(List<String> names) {
Wrap tags = new Wrap();
tags.gap(8).runGap(8);
tags.setControlSize(ControlSize.SMALL);
for (String name : names) {
tags.add(new Button(name).setStyle(Button.Style.SECONDARY));
}
return tags;
}
static Grid details(String name, String location) {
TextField nameField = new TextField();
nameField.setText(name);
TextField locationField = new TextField();
locationField.setText(location);
Checkbox shared = new Checkbox(Checkbox.Variant.BOX, "Shared");
shared.setChecked(true);
Grid form = new Grid(2);
form.gap(12).rowGap(10).expandedColumn(1)
.columnAlignment(1, Flex.CrossAlignment.STRETCH);
form.add(new Label("Name").setMuted(true).setLabelFor(nameField));
form.add(nameField);
form.add(new Label("Location").setMuted(true).setLabelFor(locationField));
form.add(locationField);
form.add(new SizedBox(0, 0)); // an empty cell: the checkbox carries its own caption
form.add(shared);
return form;
}
static Column tiles(int perRow, List<Widget<?>> tiles) {
Column rows = new Column();
rows.gap(14).crossAlignment(Flex.CrossAlignment.STRETCH);
for (int start = 0; start < tiles.size(); start += perRow) {
Row line = new Row();
line.gap(14).crossAlignment(Flex.CrossAlignment.START);
for (int i = start; i < Math.min(start + perRow, tiles.size()); i++) {
line.add(Expanded.of(tiles.get(i), 1));
}
for (int i = tiles.size(); i < start + perRow; i++) {
line.add(Expanded.spacer(1));
}
rows.add(line);
}
return rows;
}Все ступени размера
Одни и те же пять элементов управления, пять раз, от XSMALL сверху до XLARGE снизу. Ни одному из них не задана ни ширина, ни шрифт, ни отступ: каждому ряду сообщается только размер элемента, а отступы, типографика, радиусы углов и зоны нажатия двигаются вместе.


3D-вьюпорт
Физически корректные материалы под тремя источниками света, отрисованные в линейную цель с расширенным динамическим диапазоном и скомпонованные как двумерный слой. Прокручиваемая область обрезает его как любой другой виджет. Тяните, чтобы вращать, крутите колесо, чтобы приблизить.


Редактор темы — программа, которая пишет файл
Палитра хранится как данные, поэтому её правка и есть экран, и этот экран — программа, которую вы запускаете, а не зависимость, которую несёт ваше приложение. Редактор сохраняет файл темы, а ваше приложение читает его через ThemeFormat, одним лишь limn-toolkit. Потяните ползунок скругления, и окно переодевается в том же кадре: каждое поле, каждая кнопка, каждый образец цвета. Отчёт рядом измеряет каждую краску против всех поверхностей, на которые она может попасть, так что палитра, проваливающая контраст, проваливает его на виду.
jbang theme-editor-toolkit/limn-toolkitjbang --java-options=-XstartOnFirstThread theme-editor-toolkit/limn-toolkit


