7. 高度なUI(仮想化テーブル・リスト・Dock)
なぜ「仮想化(Virtualization)」が必要なのか
ログビューア、データベース管理ツール、ファイルマネージャー、金融取引ツールなどを作るとき、1 万件や 10 万件といった大量のデータを一覧表示したい場面がよくあります。
もし 10 万件のデータをそのまま div().children(items.iter().map(...)) で画面に並べたらどうなるでしょうか。
実際には画面の高さ(たとえば 800 ピクセル)には 20〜30 行しか映らないにもかかわらず、GPUI は裏側で 10 万個すべての要素のレイアウト計算(Taffy)と描画準備を毎フレーム行うことになり、いかに高速な GPU であっても動作が重くなってしまいます。
これを解決するのが UI の仮想化(Virtualization) です。
[ データ全体: 100,000 行 ]
┌────────────────────────┐
│ 0行目 〜 499行目 │ ← 要素すら生成しない(スキップ)
├════════════════════════┤
║ 500行目 (画面に表示中) ║ ┐
║ 501行目 (画面に表示中) ║ ├─ 画面に見えている 約25行分 だけ
║ ... ║ │ Render / Layout を実行する!
║ 524行目 (画面に表示中) ║ ┘
├════════════════════════┤
│ 525行目 〜 99,999行目 │ ← 要素すら生成しない(スキップ)
└────────────────────────┘
仮想化リストや仮想化テーブルは、現在のスクロール位置とウィンドウの高さから 「いま画面に見えているのは何行目から何行目までか」 を瞬時に計算し、その見えている数十行分だけ render を呼び出します。
そのため、データが 100 件でも 100 万件でも、メモリ消費量と描画速度(120 FPS)がまったく変わらないという驚異的な性能を発揮します。
GPUI 本体には基本的な uniform_list(すべての行が同じ高さのリスト)が備わっており、GPUI-Component はそれをさらに発展させた VirtualList、検索・選択機能付きの List、そして高機能な Table を提供しています。
仮想化リスト(List / VirtualList)の実装
GPUI-Component の List コンポーネントは、大量の項目を高速に表示するだけでなく、上部の検索ボックスによる絞り込み、キーボードの上下矢印キーでの移動、選択状態の管理などをセットで提供します。
List を使うときは、「データと見た目の作り方」を定義する ListDelegate トレイト を自分の構造体に実装し、それを ListState に渡します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::{
list::{List, ListDelegate, ListItem, ListState},
IndexPath,
};
// 1. データと描画ルールを持つデリゲート構造体を定義する
pub struct FileListDelegate {
files: Vec<SharedString>,
selected_index: Option<IndexPath>,
}
impl ListDelegate for FileListDelegate {
type Item = ListItem;
// 項目が全部で何件あるかを返す
fn items_count(&self, _section: usize, _cx: &App) -> usize {
self.files.len()
}
// 指定された行(ix.row)の見た目を組み立てて返す(画面に見えている行だけ呼ばれる!)
fn render_item(
&mut self,
ix: IndexPath,
_window: &mut Window,
_cx: &mut Context<ListState<Self>>,
) -> Option<Self::Item> {
let file_name = self.files.get(ix.row)?.clone();
let is_selected = self.selected_index == Some(ix);
Some(
ListItem::new(ix)
.selected(is_selected)
.py_1()
.px_3()
.child(file_name),
)
}
// 項目がクリックされたり選択されたりしたときに呼ばれる
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
_window: &mut Window,
cx: &mut Context<ListState<Self>>,
) {
self.selected_index = ix;
cx.notify();
}
}
}
このデリゲートを作ったら、親ビューの初期化時に cx.new(|cx| ListState::new(delegate, window, cx)) で状態エンティティを作成し、render メソッドの中で List::new(&self.list_state) を配置するだけで、何万件でも滑らかに動くリストが完成します。
高機能データテーブル(TableとTableDelegate)
業務アプリケーションやデータ分析ツールで最も重宝するのが Table(データテーブル) コンポーネントです。
行(縦方向)だけでなく 列(横方向)の仮想化 にも対応しており、以下のような機能を標準で備えています。
- 列幅のマウスドラッグによるリサイズ
- 特定カラムの左右への固定(ピン留め)
- 列ヘッダーをクリックしたときの昇順・降順ソート(並び替え)
- セル単位または行単位の選択とスクロール追従
テーブルの使い方もリストとまったく同じで、TableDelegate トレイト を実装して TableState に渡します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::table::{Column, ColumnSort, Table, TableDelegate, TableState};
// 1行分のデータ構造
#[derive(Clone)]
pub struct StockRow {
pub symbol: SharedString,
pub name: SharedString,
pub price: f64,
}
// テーブルのデリゲート構造体
pub struct StockTableDelegate {
rows: Vec<StockRow>,
columns: Vec<Column>,
}
impl StockTableDelegate {
pub fn new(rows: Vec<StockRow>) -> Self {
// 列(カラム)の設定:キー名、表示名、幅、ソート可否などを定義する
let columns = vec![
Column::new("symbol", "銘柄コード").width(px(120.0)).sortable(),
Column::new("name", "企業名").width(px(220.0)).resizable(true),
Column::new("price", "現在値").width(px(120.0)).text_right().sortable(),
];
Self { rows, columns }
}
}
impl TableDelegate for StockTableDelegate {
// 列の数を返す
fn columns_count(&self, _cx: &App) -> usize {
self.columns.len()
}
// 行の数を返す
fn rows_count(&self, _cx: &App) -> usize {
self.rows.len()
}
// 指定番号の列定義を返す
fn column(&self, col_ix: usize, _cx: &App) -> &Column {
&self.columns[col_ix]
}
// 各セル(row_ix 行目、col_ix 列目)の見た目を返す
fn render_td(
&mut self,
row_ix: usize,
col_ix: usize,
_window: &mut Window,
_cx: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let row = &self.rows[row_ix];
match col_ix {
0 => div().font_weight(FontWeight::BOLD).child(row.symbol.clone()),
1 => div().child(row.name.clone()),
2 => div().child(format!("{:.2}", row.price)),
_ => div(),
}
}
// ヘッダーのソートボタンが押されたときの並び替え処理
fn perform_sort(
&mut self,
col_ix: usize,
sort: ColumnSort,
_window: &mut Window,
cx: &mut Context<TableState<Self>>,
) {
let is_asc = matches!(sort, ColumnSort::Ascending);
match col_ix {
0 => self.rows.sort_by(|a, b| {
if is_asc { a.symbol.cmp(&b.symbol) } else { b.symbol.cmp(&a.symbol) }
}),
2 => self.rows.sort_by(|a, b| {
let cmp = a.price.partial_cmp(&b.price).unwrap();
if is_asc { cmp } else { cmp.reverse() }
}),
_ => {}
}
cx.notify();
}
}
}
あとは親ビューで let table_state = cx.new(|cx| TableState::new(delegate, window, cx)); を作り、画面に Table::new(&self.table_state).stripe(true)(1 行おきの縞模様表示)を置くだけで、プロ仕様のデータグリッドが動作します。
マルチペインを構築するDockシステム
VS Code や Zed、あるいは Adobe 製品や金融トレーディングアプリのように、「画面の左にファイルツリー、中央に複数のタブ、下にターミナル、右にプロパティ画面を配置し、ユーザーがタブをドラッグして自由に画面分割を変えられる」という UI を ドックシステム(Dock Layout) と呼びます。
これをゼロから実装するのは至難の業ですが、GPUI-Component には完成された DockArea と Panel の仕組みが組み込まれています。
graph TD
subgraph DockArea["DockArea(ウィンドウ全体のドック領域)"]
LeftDock["Left Dock<br/>(サイドバーパネル)"]
subgraph CenterSplit["Center DockItem(分割・タブ領域)"]
TabPanel1["Tab: エディタパネル 1"]
TabPanel2["Tab: エディタパネル 2"]
BottomPanel["Bottom Split: ログ出力パネル"]
end
RightDock["Right Dock<br/>(インスペクタパネル)"]
end
▸ 1. 自分のビューに Panel トレイトを実装する
ドックの中に配置したい各画面(ビュー)には、通常の Render と Focusable に加えて Panel トレイト を実装します。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::dock::{Panel, PanelEvent};
pub struct LogPanel {
focus_handle: FocusHandle,
}
impl EventEmitter<PanelEvent> for LogPanel {}
impl Focusable for LogPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
// Panel トレイトを実装すると、Dock のタブとして扱えるようになる
impl Panel for LogPanel {
fn panel_name(&self) -> &'static str {
"LogPanel" // 保存・復元に使われる識別名
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
"システムログ" // タブに表示されるタイトル
}
}
impl Render for LogPanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().p_4().child("ここにログが出力されます...")
}
}
}
▸ 2. DockArea を作成してパネルを配置する
メインウィンドウ側では、DockArea::new(...) を作成し、DockItem::split(上下左右の分割)や DockItem::tabs(タブグループ)を使って初期レイアウトを組み立てます。
#![allow(unused)]
fn main() {
use gpui::*;
use gpui_component::dock::{DockArea, DockItem, DockPlacement};
use std::sync::Arc;
fn setup_dock(window: &mut Window, cx: &mut App) -> Entity<DockArea> {
// 1. DockArea を作成する
let dock_area = cx.new(|cx| DockArea::new("main-dock", Some(1), window, cx));
let weak_dock = dock_area.downgrade();
// 2. 中央に配置するパネルを作成する
let editor_panel = cx.new(|cx| LogPanel { focus_handle: cx.focus_handle() });
let preview_panel = cx.new(|cx| LogPanel { focus_handle: cx.focus_handle() });
// 3. 中央を左右(Horizontal)に分割してセットする
let center_layout = DockItem::h_split(
vec![
DockItem::tab(editor_panel, &weak_dock, window, cx),
DockItem::tab(preview_panel, &weak_dock, window, cx),
],
&weak_dock,
window,
cx,
);
dock_area.update(cx, |area, cx| {
area.set_center(center_layout, window, cx);
});
dock_area
}
}
これだけで、境界線をドラッグしてサイズを変えたり、タブを引っ張って別の場所にドッキングさせたり、パネルを最大化・クローズしたりできる本格的なワークスペース画面が手に入ります。
レイアウト状態の保存と復元
ドックシステムを使うアプリでは、ユーザーがせっかく自分好みに画面を分割しても、アプリを再起動したときに初期状態に戻ってしまうと不便です。
DockArea には、現在のパネル配置や分割比率を丸ごとデータ構造(DockAreaState)として書き出す dump(&self, cx: &App) メソッドと、そこから配置を復元する load(...) メソッドが用意されています。
#![allow(unused)]
fn main() {
// 1. 現在のレイアウト状態を JSON 文字列として保存する
let state = dock_area.read(cx).dump(cx);
let json_str = serde_json::to_string_pretty(&state).unwrap();
std::fs::write("layout.json", json_str).ok();
}
次回起動時には、あらかじめ PanelRegistry にパネルの生成関数を登録しておき、serde_json::from_str で読み込んだ DockAreaState を dock_area.update(cx, |area, cx| area.load(state, window, cx)) に渡すだけで、前回終了したときと寸分違わぬ画面レイアウトが復元されます。
ここまでで、GPUI の基礎から GPUI-Component の高度なウィジェットまでを一通り学びました。 次章では、これらの知識をすべて組み合わせて、1 つの動くデスクトップアプリケーション「TaskFlow(タスク管理アプリ)」をゼロから完成させます。