简体中文
GitHub
主题

完整界面

界面

不是裁剪图,也不是效果图。每一张都是本站构建期间工具包渲染出的窗口。

演示应用

一个订单界面——可排序、可筛选的表格,每个订单在从旁边滑入的面板中打开——侧边按类别列出所有组件。顶部的四个选择器切换整个窗口的语言、方向、尺寸和主题。

jbang demo@limn-toolkit/limn-toolkit
jbang --java-options=-XstartOnFirstThread demo@limn-toolkit/limn-toolkit
The demo application
The demo application

同一窗口,从右向左

在 ar 翻译下把方向轴翻转的演示应用:菜单栏、导航、输入框、滚动条和表格都从右侧读起,其中的文字按阿拉伯文应有的方式连写与重排。没有重写任何界面——方向是一条继承的轴,像控件尺寸一样设置。

The demo application in Arabic, right to left
The demo application in Arabic, right to left

一个表单

标签、带校验的字段、一个选择项、一个开关和操作行:表单指南里的完整示例。

A form
A form
代码
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;
}

完成布局的窗口

工具栏、内容区旁的侧边栏,以及一行状态:布局指南里的完整示例。

A window laid out
A window laid out
代码
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。没有一个被指定宽度、字体或内边距:每一行只被告知一个控件尺寸,而内边距、字号、圆角与点按区域会一起变化。

Every size step
Every size step

3D 视口

三盏灯下的基于物理的材质,渲染到线性高动态范围目标,再作为一个 2D 图层合成。滚动视图裁剪它,和裁剪其他任何组件一样。拖动可环绕观察,滚动可缩放。

The 3D viewport
The 3D viewport

主题编辑器,一个写出文件的程序

配色就是数据,所以编辑它本身就是一个界面。而这个界面是一个你运行的程序,不是你的应用要背上的依赖。它保存一个主题文件,你的应用只靠 limn-toolkit,用 ThemeFormat 读回去。拖动圆角滑块,窗口就在同一帧里换装:画面里的每个输入框、按钮和色块。旁边的报告会把每种墨色对着它可能落下的每一种表面来量,所以对比度不过关的配色会当场露馅。

jbang theme-editor@limn-toolkit/limn-toolkit
jbang --java-options=-XstartOnFirstThread theme-editor@limn-toolkit/limn-toolkit
The theme editor
The theme editor