月刊にはなってないからセーフ。
基本的に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したい行を選ぶ
ですが、選ぶためには表示しなければいけません。
今回は、表示の話です。
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は上記のような構造になっていますが、
- どのファイルのdiffなのかをhunk単位で分かると嬉しい
- ヘッダー部分をクリックすることで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を使います。
いろいろ試した結果、テキストだけの行と画像だけの行に分けて、
以下の順序で更新すると、望ましい感じのパフォーマンスとなりました。
- テキスト行の高さを先に決める
- 画像行の画像の非同期取得を開始する
- 画像を取得出来たら、画像からその行の高さを決める
以下は、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です。
まだバグや課題はありますが、コミットまではできます。
そろそろ履歴を表示できるようにしたいなぁ、というところです。
あとがき
次回予告
書き始めたら、
- 頑張った工夫が無くても動くことが分かったり
- 日本語ファイル名を扱えないことに気づいたり
なんだかんだで疲れてきたので、選択は次号に回しました。
近況
このシリーズとアプリを書き始めたのは、
退職して無職をエンジョイしてたからでした。
まだしばらく働くつもりはなかったんですが、
空から仕事が降ってきたので、流れでフリーランサーになりました。
プログラマになったのも流れだったので、このまま流れていくのでしょう。
直近ではiOSアプリの改修です。
まだコードは書いていませんが、自作のアプリケーションでgit commit
するはずです。
(まだmacで動かしてないけど、俺はJVMを信じてる)
手元に業務があると、業務外でツール作るモチベーションも上がりますね。
Swing
Swingで書いてるのは特に理由も無く、
「CSSを書きたくない」ぐらいしかコダワリも無くて、
デザインのための仮実装ぐらいのつもりだったんですが、
特に問題も無いので、このまま続ける予定です。
配布する場合は、どういう形がいいのかなぁ。