Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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(タスク管理アプリ)」をゼロから完成させます。