3. レイアウトとスタイリング
宣言的UIツリーとElementの仕組み
GPUI で画面を作るときは、HTML で <div> タグの中に文字や別の <div> を入れていくのと同じように、関数とメソッドチェーンを組み合わせて 要素(Element)のツリー構造 を組み立てます。
このとき中心となるのが ParentElement トレイトと IntoElement トレイトです。
div(): 最も基本的な箱(コンテナ)を作る関数です。HTML の<div>に相当します。.child(...): 1 つの子要素を箱の中に追加します。.children(...): ベクタ(Vec)やイテレータを使って、複数の子要素をまとめて箱の中に追加します。
#![allow(unused)]
fn main() {
use gpui::*;
fn build_card() -> impl IntoElement {
let tags = vec!["Rust", "GPUI", "Desktop"];
div()
.p_4()
.child("カードタイトル") // 文字列(&str や String)はそのまま子要素になる
.child(
div()
.flex()
.gap_2()
// イテレータで複数の子要素をまとめて追加する
.children(tags.into_iter().map(|tag| {
div().px_2().py_1().bg(rgb(0xe2e8f0)).child(tag)
})),
)
}
}
文字列(&'static str、String、SharedString)や div()、SVG 画像、さらには前章で学んだ子ビュー(Entity<MyView>)まで、画面に表示できるものはすべて IntoElement トレイトを実装しているため、.child() にそのまま渡すことができます。
▸ 文字列を高速に扱う SharedString
GUI では同じラベル文字列を毎フレーム何度も受け渡します。
毎回 String::clone() でメモリのコピーが発生するのを防ぐため、GPUI では文字列リテラル(&'static str)と参照カウント文字列(Arc<str>)を統合した SharedString 型が広く使われます。
"テキスト".into() と書くだけでコストゼロで変換できるため、カスタム部品のフィールドには SharedString を使うのが定石です。
TaffyによるFlexboxレイアウト
GPUI のレイアウト計算エンジンには、Rust 製の高速なライブラリ Taffy が組み込まれています。 Web 開発(CSS)で標準的に使われている Flexbox(フレックスボックス) のルールがそのまま動くため、Web フロントエンドの経験があれば迷うことなく配置を決められます。
+---------------------------------------------------+
| .flex().flex_col().gap_4().p_4() |
| +---------------------------------------------+ |
| | ヘッダー領域 (.h(px(48.0))) | |
| +---------------------------------------------+ |
| +---------------------------------------------+ |
| | メイン領域 (.flex().flex_row().flex_1()) | |
| | +---------------+ +--------------------+ | |
| | | サイドバー | | コンテンツ | | |
| | | (.w(px(200.)))| | (.flex_1()) | | |
| | +---------------+ +--------------------+ | |
| +---------------------------------------------+ |
+---------------------------------------------------+
よく使う Flexbox のメソッドは以下のとおりです。
| メソッド | CSS での相当指定 | 役割・動作 |
|---|---|---|
.flex() | display: flex | Flexbox レイアウトを有効にする |
.flex_row() | flex-direction: row | 子要素を 横方向(左から右) に並べる(既定) |
.flex_col() | flex-direction: column | 子要素を 縦方向(上から下) に並べる |
.flex_1() | flex: 1 1 0% | 親の余っているスペースいっぱいに伸縮する |
.flex_shrink_0() | flex-shrink: 0 | スペースが足りなくても縮まないように固定する |
.items_center() | align-items: center | 交差軸(横並びなら上下方向)の中央に揃える |
.justify_between() | justify-content: space-between | 主軸の両端に寄せ、均等に間隔を空ける |
.justify_center() | justify-content: center | 主軸の中央に集める |
.gap_2() / .gap(px(8.)) | gap: 0.5rem / 8px | 並んでいる子要素どうしの隙間を一括で空ける |
また、親要素からはみ出た部分を隠す .overflow_hidden() や、絶対配置を行う .relative() と .absolute().top_0().right_0() なども CSS とまったく同じ感覚で使えます。
Tailwind風のメソッドチェーン記法
GPUI では Styled トレイトによって、Tailwind CSS のユーティリティクラスをそのまま Rust のメソッドにしたようなデザイン記法が提供されています。
CSS ファイルを別個に管理する必要がなく、Rust の型チェックと入力補完(オートコンプリート)を効かせながら見た目を整えられます。
▸ 1. サイズと単位(px, rems, relative)
長さを指定するときは、専用の単位関数を使います。
px(120.0): ピクセル単位(論理ピクセル、高解像度ディスプレイでも自動スケーリングされます)。rems(1.5): ルートフォントサイズに対する相対単位です。relative(0.5): 親要素のサイズに対する割合(0.5なら50%)です。.size_full()/.w_full()/.h_full(): 幅や高さを親要素の100%に広げます。
▸ 2. 余白(Padding と Margin)
Tailwind CSS と同じく、数値ステップ(1 = 0.25rem 相当)または直接ピクセル値で余白を指定します。
.p_4(): 上下左右すべてにパディングを入れる.px_3()/.py_2(): 左右(X 軸)のみ、上下(Y 軸)のみにパディングを入れる.pt_2()/.pb_2()/.pl_4()/.pr_4(): 上・下・左・右の個別に指定する.m_2()/.mx_auto()/.mt_4(): 外側の余白(マージン)を指定する
▸ 3. 色(Color)と背景・枠線・角丸・影
色の指定には rgb(0xRRGGBB)、rgba(0xRRGGBBAA)、または hsla(h, s, l, a) 関数を使います。
#![allow(unused)]
fn main() {
div()
.w(px(300.0))
.p_4()
.bg(rgb(0x1e293b)) // 背景色(ダークブルーグレー)
.text_color(rgb(0xf8fafc)) // 文字色(オフホワイト)
.text_sm() // 文字サイズ(小さめ)
.font_weight(FontWeight::BOLD) // 太字
.border_1() // 1px の枠線を有効化
.border_color(rgb(0x334155))// 枠線の色
.rounded_lg() // 角丸(大きめ)
.shadow_md() // ドロップシャドウ(中くらいの影)
.child("スタイリッシュなボックス")
}
注意点として、枠線を表示したいときは .border_color(...) だけでなく、必ず太さを指定する .border_1()(または .border_2()、下線のみの .border_b_1() など)をセットで呼び出す必要があります。
条件付きスタイルとインタラクティブ状態
「マウスが乗ったときに背景を明るくしたい」「選択されているときだけ青い枠線をつけたい」といった動的な変化も、メソッドチェーンを途切れさせずに書くことができます。
▸ ホバー(.hover)とアクティブ(.active)
インタラクティブな状態変化を使うには、まず要素に .id("一意な名前") を付けて Stateful<Div> に変換します。
すると、.hover() や .active() メソッドが使えるようになります。
#![allow(unused)]
fn main() {
div()
.id("menu-item-1") // インタラクティブ要素には ID が必要
.px_3()
.py_2()
.rounded_md()
.cursor_pointer() // マウスカーソルを指マークにする
.bg(rgb(0x1e293b))
.hover(|style| style.bg(rgb(0x334155))) // マウスが乗ったときの色
.active(|style| style.bg(rgb(0x0f172a))) // クリックしている最中の色
.child("メニュー項目")
}
▸ 条件分岐ヘルパー(.when と .when_some)
Rust の if 文を使うために一度変数に代入すると、メソッドチェーンが途切れて読みづらくなります。
GPUI の FluentBuilder トレイト(gpui::prelude::* に含まれます)には、チェーンの中で条件分岐を行う .when() と .when_some() が用意されています。
#![allow(unused)]
fn main() {
use gpui::prelude::*;
use gpui::*;
fn render_item(is_selected: bool, badge_text: Option<String>) -> impl IntoElement {
div()
.p_3()
.rounded_md()
.bg(rgb(0xffffff))
// 1. is_selected が true のときだけスタイルを追加する
.when(is_selected, |this| {
this.bg(rgb(0xeff6ff))
.border_1()
.border_color(rgb(0x3b82f6))
})
.child("項目タイトル")
// 2. Option が Some(val) のときだけ中身を取り出して子要素を追加する
.when_some(badge_text, |this, text| {
this.child(
div().px_2().bg(rgb(0xef4444)).text_color(white()).child(text),
)
})
}
}
この .when() パターンを使うことで、条件に応じたスタイルの切り替えや子要素の出し分けを上から下へ自然に読むことができます。
ステートレスな部品を作るRenderOnce
第 2 章では、独自の内部状態を持つ部品として Render トレイト(Entity<T>)を学びました。
しかし、アプリを作っていると「バッジ」「見出し付きカード」「ステータスアイコン」のように、自分自身の状態を持たず、親から渡されたプロパティ(引数)を表示するだけの再利用パーツ をたくさん作りたくなります。
そのような毎回使い捨てできる軽量な部品を作るために用意されているのが RenderOnce トレイト と #[derive(IntoElement)] マクロ です。
#![allow(unused)]
fn main() {
use gpui::*;
// 1. #[derive(IntoElement)] を付けると、そのまま .child() に渡せるようになる
#[derive(IntoElement)]
pub struct StatusBadge {
label: SharedString,
is_online: bool,
}
impl StatusBadge {
pub fn new(label: impl Into<SharedString>, is_online: bool) -> Self {
Self {
label: label.into(),
is_online,
}
}
}
// 2. RenderOnce を実装する(第1引数が &mut self ではなく self である点に注目!)
impl RenderOnce for StatusBadge {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let dot_color = if self.is_online {
rgb(0x22c55e) // 緑
} else {
rgb(0x94a3b8) // グレー
};
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded_full()
.bg(rgb(0xf1f5f9))
.child(div().size(px(8.0)).rounded_full().bg(dot_color))
.child(self.label)
}
}
}
これを使う側は、cx.new() で保管庫に登録する必要すらありません。
親の render メソッドの中で .child(StatusBadge::new("稼働中", true)) と書くだけで、標準の div() と同じように軽快に配置できます。
| トレイト | 第1引数 | 主な用途 | 使い方 |
|---|---|---|---|
Render | &mut self | 自身の状態を持ち、イベントで変化する画面・領域 | cx.new(|cx| MyView::new(cx)) で Entity として保持 |
RenderOnce | self(消費) | 親から値を受け取って描画するだけの軽量な UI 部品 | MyWidget::new() を直接 .child() に渡す |
この Render と RenderOnce の使い分けは、第 5 章以降で扱う GPUI-Component の設計でもそのまま使われている重要な基本パターンです。
次章では、作成した画面に命を吹き込む「イベント・アクション・非同期処理」の仕組みを学びます。