LoginSignup
0
0

More than 5 years have passed since last update.

週刊 git GUIクライアントを作る [5] 初めてのデスクトップアプリ

Posted at

月刊にはなってないからセーフ。
基本的にSwingの話。

前号までのまとめ

[1] stage/unstage基礎知識編

行単位のstage/unstageのためにやるべきこと5つ

1. 元になるpatchとしてdiffを出力する
2. stage/unstageしたい行を選ぶ
3. hunkのボディを編集する
4. hunkのヘッダーを再計算する
5. 最終的なpatchをapplyする

[2] stage/unstage不完全攻略編

JGitを使ってみたら無理だったので、git.exeを使おうというお話でした。

[3] stage/unstageパッチ編集編

3. hunkのボディを編集する
4. hunkのヘッダーを再計算する

[4] diff et al.

1. 元になるpatchとしてdiffを出力する

今号は

2. stage/unstageしたい行を選ぶ

進捗.gif

ですが、選ぶためには表示しなければいけません。
今回は、表示の話です。

UIを考えるの難しい

GUIアプリケーションとしては

  • 何をクリックできるのか
  • 何を選択できるのか

等をデザインに反映させる必要がありそうですが、
ここでは基本的に考えてません。
「ぼくのかんがえたさいきょうのgitくらいあんと」を作りたいので

  • どう表示されると自分が嬉しいか
  • どう操作できると自分が嬉しいか

だけ考えて、各位幸せになりましょう。

例えばUITableView

まず最初にイメージしたのは、
iOSのUITableViewのようなセクションのあるコレクション系のViewです。
しかし、あなたとJava、ここはJVMです。

てっとりばやくSwingでdiffの部分選択をするためのUIとして、
JListがいちばん楽そうなので採用しました。
パッチの元となるdiffを1行ずつリストに表示して、選択させる、というわけです。

行単位の更新をしたくなったのでJTableにしましたが(後述)、
1列だけのJTableなので、基本的な使い方は同じです。

モ並感

ところで、リストで表示するとしても、diffを1行ずつ表示する必要はありませんね。
リストの各行の内部にテキスト表示して、そこで選択させればいいわけです。
選択の単位はリストの行だと、自然に考えてしまっていました。

このあたり、モバイルアプリケーション開発から抜け切れてない感じです。
(私は基本的にiOSやAndroidのネイティブアプリ開発を生業としています)

選択後のstage/unstageのトリガー

選択してから、stage/unstageさせるトリガーとなるアクションが必要です。
SourceTreeを見ると、ボタンと右クリックのメニューがありますね。
右クリックは常用するには面倒です。
ボタンの位置や形を考えるのも面倒です。

というわけで、diffのヘッダー部分をクリックさせることにしました。

Diffの表示

hunk

git diffでもらえるデータをそのまま表示したほうが楽ですので、hunk単位で表示しましょう。

ここで言うdiffのヘッダーは、それぞれのファイルごとに複数行あり、
diff --gitから始まる行からhunkのヘッダー行の手前までのことです。
ここで言うhunkのヘッダーは、@@から始まる1行のことです。

<diff-per-file>
    <diff-header />
    <hunk>
        <hunk-header />
        <hunk-body />
    </hunk>
    <hunk>
        <hunk-header />
        <hunk-body />
    </hunk>
</diff-per-file>

<!-- 画像など、hunkが無い場合もある -->
<diff-per-file>
    <diff-header />
</diff-per-file>

gitのdiffは上記のような構造になっていますが、

  1. どのファイルのdiffなのかをhunk単位で分かると嬉しい
  2. ヘッダー部分をクリックすることでstage/unstageするようにしているので、広いほうが嬉しい

という理由から、diffのヘッダーをそれぞれのhunkと一緒に表示します。

<diff-per-file>
    <diff-header />
    <hunk>
        <hunk-header />
        <hunk-body />
    </hunk>
    <diff-header />
    <hunk>
        <hunk-header />
        <hunk-body />
    </hunk>
</diff-per-file>

<diff-per-file>
    <diff-header />
    <hunk>
        <hunk-header dummy="new file" />
        <hunk-body img="icon.png" />
    </hunk>
</diff-per-file>

同じdiffの複数のhunkをまとめるとdiffのヘッダーが重複しますが、
まとめてパッチに出力しても特に問題は無いようです。
(ただ、私は基本的にgit applyはhunk単位でしか行っていないので、隠れた問題があるかもしれません)

hunk ヘッダー

  • ヘッダーの情報を個別に選択させる必要はない
  • ヘッダーのクリックをそのhunkのstage/unstageのトリガーとしたい

ので、リストの1行に、テキストを複数行表示させてしまいます。
JLabelなどはhtml(のようなもの?)を<html></html>でまとめて突っ込めます。

Collector<CharSequence, ?, String> toHtml = Collectors.joining(
            "</nobr><br><nobr>",
            "<html><nobr>",
            "</nobr></html>");
Arrays.stream(ヘッダー部分の文字列.split("\n")).collect(toHtml)

<nobr></nobr>は、現在または将来どの環境でも使えるのか怪しいですが、便利です。

hunk ボディ

git diffの出力そのままです。
追加行、削除行、コンテクスト行で色が違うと分かりやすいです。

画像の非同期取得

画像(などバイナリファイル)の追加や削除の場合、ファイル単位で扱うので、
hunkという単位は存在せず、git diffの出力にも現れません。

でも、せっかくなので画像は表示させたいですね。
前号では、その方法や注意点などについて言及しました。

リストで表示する場合、

  • 他のhunkと違い、git diff時点では画像ファイルを得ていない
  • 画像オブジェクトを作成するのに多少は時間がかかる

という点を考慮して、表示方法が2つあります。

  • 画像の準備を待ってからリストを表示する
  • リストに各hunkのテキストを表示し、画像だけ後から更新する

待つのは嬉しくないので、表示できるものは先に表示させましょう。

(まさか実装が面倒になると思わなかったので、当たり前のように後者を選びましたが、ネットワーク経由ではなく、ローカルマシンの性能依存なので、待つのも全然アリだとは思います。)

リスト表示を行単位で更新する

ところがJListは行単位の更新ができないようです。
なので列が1つだけのJTableを使います。

いろいろ試した結果、テキストだけの行と画像だけの行に分けて、
以下の順序で更新すると、望ましい感じのパフォーマンスとなりました。

  1. テキスト行の高さを先に決める
  2. 画像行の画像の非同期取得を開始する
  3. 画像を取得出来たら、画像からその行の高さを決める

以下は、Swingの内部実装に依存したhackを含みますので、
バージョンによっては同じ発想、同じコードが通用しない可能性はあります。
確認しているのはjavac 1.8.0_144です。
(javacのバージョンと同じ、という理解であってます?)
(ところで今後、Swingの既存コンポーネントに劇的に手が入る予定ってあるんですか?)

テキスト行の高さを先に決める

JTableは各行の表示・更新が必要になると各列ごとに設定したTableCellRendererが呼ばれます。
TableCellRendererはinterfaceです。

public interface TableCellRenderer {

    Component getTableCellRendererComponent(JTable table, 
                                            Object value,
                                            boolean isSelected, 
                                            boolean hasFocus,
                                            int row, 
                                            int column);
}

各行の更新する方法はいくつかあるはずですが、
ここではJTable#setRowHeight(int row, int rowHeight)を使います。
行ごとに高さを変えるために必須なのです。

テキストの高さはJLabelにテキストを入れたら分かりますので、
TableCellRendererが呼ばれる前にJLabelを作って高さを設定します。

各行の高さを更新するのは、テーブルに紐づくデータに変更があったタイミングです。
サンプルコードです。

// テキスト行のsetRowHeightを先にまとめてやる
preRender = () -> {
    // 以下のいずれかを実行しないと、テーブルの最大行数が最初に表示した行数に依存してしまう
    // おそらく、JTable内部でrowModel = null;が必要なのだと思う
    this.table.setRowHeight(1);
//    setRowSorter(null);
//    tableChanged(new TableModelEvent(getModel()));
    final int size = dataModel.getSize();
    for (int i = 0; i < size; i++) {
        final ListItem item = dataModel.getElementAt(i);
        final ImageHolder imageHolder = item.imageHolder();
        // 画像行ではない場合
        if (imageHolder == null || !imageHolder.hasLoader()) {
            // 大したコストではないはずなのでキャッシュしない
            final JLabel label = new JLabel();
            this.renderer.render(label, item, false, null);
            final int height = label.getPreferredSize().height;
            table.setRowHeight(i, height);
        }
    }
};

サンプルコード内に書いていますが、注意点としては、

  • this.table.setRowHeight(1);
  • setRowSorter(null);
  • tableChanged(new TableModelEvent(getModel()));

などを呼ばないと、テーブルの最大行数が最初に表示した行数に依存してしまいます。
最初に3行表示すると、そのテーブルは3行しか表示できません。
何故こんなことになるのかはよく分かっていませんが、
JTableのprivate SizeSequence rowModel;を初期化すると想定通りの挙動になるようです。

同じ行に対してJTable#setRowHeight(int row, int rowHeight)を複数回呼んではいけない、
ということなのかもしれません。

画像行の画像の非同期取得を開始する

テキストと同じく、事前に開始してもいいのですが、
特にパフォーマンスに問題は感じなかったので、
TableCellRenderer#getTableCellRendererComponentで行います。

いずれにせよ、
少なくとも周辺行の更新時にTableCellRenderer#getTableCellRendererComponentは呼ばれてしまうので、
画像取得と取得後のコールバック呼び出しが1度しか行われないよう実装する必要があります。

画像を取得出来たら、画像からその行の高さを決める

TableCellRenderer#getTableCellRendererComponentの外部で表示を変更することができないので、
画像を取得したら、紐づくアイテムのほうがに画像を持たせて、
JTable#setRowHeight(int row, int rowHeight)を呼びます。
そうすると、再びTableCellRenderer#getTableCellRendererComponentが呼ばれます。

final TableColumn tableColumn = this.table.getColumnModel().getColumn(0);
tableColumn.setCellRenderer((table, value, isSelected, hasFocus, row, column) -> {
        final ListItem item = (ListItem) value;
        final JLabel label = new JLabel();
        // ここで画像取得を開始する、あるいは画像をセットする
        this.renderer.render(label, item, isSelected, imageIcon -> {
                // 画像取得後
                final int iconHeight = imageIcon.getIconHeight();
                table.setRowHeight(row, iconHeight);
        });
        return label;
});

進捗

毎度おなじみ、投稿にあんまり関係ない進捗gifです。

進捗_3.gif

まだバグや課題はありますが、コミットまではできます。
そろそろ履歴を表示できるようにしたいなぁ、というところです。

あとがき

次回予告

書き始めたら、

  • 頑張った工夫が無くても動くことが分かったり
  • 日本語ファイル名を扱えないことに気づいたり

なんだかんだで疲れてきたので、選択は次号に回しました。

近況

このシリーズとアプリを書き始めたのは、
退職して無職をエンジョイしてたからでした。

まだしばらく働くつもりはなかったんですが、
空から仕事が降ってきたので、流れでフリーランサーになりました。
プログラマになったのも流れだったので、このまま流れていくのでしょう。

直近ではiOSアプリの改修です。
まだコードは書いていませんが、自作のアプリケーションでgit commitするはずです。
(まだmacで動かしてないけど、俺はJVMを信じてる)

手元に業務があると、業務外でツール作るモチベーションも上がりますね。

Swing

Swingで書いてるのは特に理由も無く、
「CSSを書きたくない」ぐらいしかコダワリも無くて、
デザインのための仮実装ぐらいのつもりだったんですが、
特に問題も無いので、このまま続ける予定です。

配布する場合は、どういう形がいいのかなぁ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0