日本語
GitHub
テーマ

画面全体

画面例

切り抜きでもモックアップでもありません。どれも、このサイトのビルド中にツールキットが描画したウィンドウです。

デモアプリケーション

注文の画面——並べ替えと絞り込みができるテーブルと、横からスライドして開く注文のパネル——があり、横には全コンポーネントがカテゴリ別に並びます。上部の 4 つのセレクターで、ウィンドウ全体の言語、方向、サイズ、テーマを切り替えます。

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;
}

すべてのサイズ段階

同じ 5 つのコントロールを 5 回、上の XSMALL から下の XLARGE まで。幅もフォントもパディングも与えていません。各行に伝えるのはコントロールサイズだけで、パディング、文字、角の丸み、タップ領域がまとめて動きます。

Every size step
Every size step

3D ビューポート

3 灯の下の物理ベースマテリアルを、リニアのハイダイナミックレンジターゲットに描画し、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