Flutter経験者がターミナルでUIを持ったアプリケーションを作るときにちょうど良さそうなライブラリがあります。それがnoctermです。
noctermとは?
noctermはncursesやratatuiのような、端末制御ライブラリです。
Flutterによく似たコンポーネントとテーマ、ホットリロードに対応しており、Flutter経験者にとっては扱いやすいものになっています。
状態管理は組み込みのStatefulComponent (FlutterのStatefulWidget相当)、riverpod、blocに対応しているようです。
何か作ってみる
今回は、ターミナルで動作する簡単な画像ビューワーを作ってみたいと思います。
以下の点については、今回は触っていません。
- 複数のフォーカス領域を切り替える
- マウス入力
- riverpodなどによる状態管理
実行結果
最初に実行結果を記載しておきます。左側にファイラー、右側に画像表示領域を置いたシンプルな構成です。
自動車以外あまりわからないですね。他にはラーメン・海鮮丼・フグの網焼きです。
操作は次のようにしています。
| キー | 動作 |
|---|---|
| ↑ / ↓ | ファイル選択を移動 |
| Enter | ディレクトリへ移動、または画像を表示 |
| R | アスキー変換に使う文字セットを切り替え |
| Q / Ctrl+C | 終了 |
対応している画像形式は png / jpg / jpeg です。
全体の構成
作ったものを大まかに整理するとこうなります。
UI は nocterm の Row / Column / ListView / Focusable で組み、画像処理は image パッケージで行っています。
dependencies:
image: ^4.5.4
nocterm: ^0.6.0
path: ^1.9.1
Nocterm で画面を組み立てる
ソースコード全体はGitHubに置いたので、少しだけ説明します。
パッと見てわかる通り、WidgetがComponentに置き換わっている以外はFlutterとほぼ同じです。
void main() {
runApp(AppMain());
}
ルートコンポーネントでは、左側にファイラー、右側に画像ビューを置いています。Flutter と同じように StatefulComponent を使い、選択中の画像パスと文字ランプの状態を保持します。
/// アプリケーション全体のルートコンポーネント。
class AppMain extends StatefulComponent {
/// ルートコンポーネントの状態を生成する。
@override
State<AppMain> createState() => _AppState();
}
class _AppState extends State<AppMain> {
/// アスキーアート変換に使う文字セットの選択状態。
Ramp _ramp = Ramp.ramp1;
/// 現在選択されている画像ファイルのパス。
String? _imageFileName;
/// メインレイアウトを構築する。
@override
Component build(BuildContext context) {
return Container(
color: Colors.black,
child: Row(
children: [
Focusable(
focused: true,
onKeyEvent: (event) {
switch (event.logicalKey) {
case LogicalKey.keyC:
if (event.modifiers.ctrl) {
exit(0);
}
case LogicalKey.keyQ:
exit(0);
case LogicalKey.keyR:
setState(() {
_ramp = switch (_ramp) {
Ramp.ramp0 => Ramp.ramp1,
Ramp.ramp1 => Ramp.ramp2,
Ramp.ramp2 => Ramp.ramp0,
};
});
return true;
default:
break;
}
return false;
},
child: Container(
width: 30,
decoration: BoxDecoration(
border: BoxBorder(right: BorderSide(color: Colors.white)),
),
child: Filer(
focused: true,
onSelected: (file) {
setState(() {
_imageFileName = file.path;
});
},
),
),
),
Expanded(
child: ImageView(
key: Key('${_imageFileName}_$_ramp'),
fileName: _imageFileName,
ramp: _ramp,
),
),
],
),
);
}
}
キー入力
noctermで特徴的なのは Focusableコンポーネントです。キー入力を受けたい領域を Focusable で包みます。
今回実装したアプリでは、上下キーでファイル選択位置を変え、Enterでディレクトリ移動または画像選択を行います。
自動スクロール機能もあるようですが、今回はScrollControllerを使ってスクロール処理を自前で実装しています。
return Focusable(
focused: component.focused,
onKeyEvent: _handleKeyEvent,
child: ListView.builder(
controller: _controller,
itemCount: _files.length,
itemBuilder: (_, index) {
final file = _files[index];
return Text(
filerEntryLabel(file, _currentDir),
style: _getStyle(file, index),
);
},
),
);
bool _handleKeyEvent(KeyboardEvent event) {
final key = event.logicalKey;
if (key == LogicalKey.arrowUp && _selectedIndex > 0) {
final nextIndex = _selectedIndex - 1;
setState(() {
_selectedIndex = nextIndex;
});
_controller.jumpTo(nextIndex.toDouble());
return true;
}
if (key == LogicalKey.arrowDown && _selectedIndex < _files.length - 1) {
final nextIndex = _selectedIndex + 1;
setState(() {
_selectedIndex = nextIndex;
});
_controller.jumpTo(nextIndex.toDouble());
return true;
}
if (key != LogicalKey.enter) {
return false;
}
/// Enterを押下した。ディレクトリ移動・または画像ファイル選択
final file = _files[_selectedIndex];
if (file.isImageFile) {
component.onSelected(file.file);
} else if (file.isDir) {
_changeDirectory(file.path);
}
return true;
}
フォーカスについて
今回はファイラー部分だけですが、複数の領域でキー入力処理を実装する場合は以下のような感じで Focusableのfocusedプロパティを適切に設定して、フォーカスを切り替える必要がありそうです。
Focusable(
focused: _currentPaneId == thisPaneId,
onKeyEvent: (e) {
if(e.logicalKey == LogicalKey.tab) {
setState(() => _currentPaneId++);
}
:
:
}
child: Filer( ... )
:
:
)
実行する
プロジェクトルートで次のように実行します。
dart run lib/main.dart
または以下のようにバイナリにコンパイルして実行します。
dart compile exe lib/main.dart -o viewer
./viewer
実装してみてわかったこと
書き味はほとんどFlutterですが、やはりキー入力とフォーカスの制御がポイントになりそうです。
| ポイント | 内容 |
|---|---|
| nocterm の書き味 |
StatefulComponent / setState / Row / ListView など、Flutter に近い感覚で TUI を書ける |
| キー入力 | 入力を受けたい領域を Focusable で包み、onKeyEvent で処理する。複数の入力領域がある場合、コツが必要そう。 |
この先にできること
今回の実装は最小限の画像ビューワーですが、少し手を入れるだけでいろいろ拡張できそうです。
スクロール対応
大きい画像を表示したときに、表示領域からはみ出した部分をスクロールできるようにするとビューワーとして使いやすくなります。
画像形式の追加
image パッケージ自体は複数形式を扱えるので、webp や gif などを対象に含めることもできます。アニメーション GIF をフレームごとに表示するのも面白そうです。
マウス対応
ファイラーの各ファイルの表示をMouseButtonで実装すれば、マウスクリックでファイルを選択できそうです。
カラーアスキーアート
現在は濃淡だけを文字に変換していますが、元画像の RGB を TextStyle の色に反映して1文字ずつText表示すれば、カラーのアスキーアート表示もできます。
注意点
実装中に気づいたnocterm 0.6.0 + mac環境での注意点があります。
アプリケーションでCTRL+Cをハンドリングする
CTRL+Cをハンドリングしてアプリケーションコードでexitしています。
アプリケーションでハンドリングせずに、ターミナルのSIGINTに任せると、終了したように見えても実際はプロセスが残ったままになっていました。
Focusable(
focused: true,
onKeyEvent: (event) {
switch (event.logicalKey) {
case LogicalKey.keyC:
if (event.modifiers.ctrl) {
exit(0);
}
case LogicalKey.keyQ:
:
:
終了後にマウスイベントのハンドラが解放されない
noctermはマウス操作に対応していますが、アプリケーションを終了してもハンドラの設定が解除されないようです。
ターミナル上でマウスを移動させると、制御コードが表示されてしまいます。
描画サイズがわからない(かもしれない)
ドキュメントによると BuildContextの constraints.maxWidth/maxHeight で取得できるようですが、試したところ例外が発生していました。
noctermのコンポーネント同士はうまくやっているようなので、自分の使い方が良くないのかもしれません。
ソースコード
ソースコードはこちらのリポジトリにあります。
まとめ
まだバージョン1.0.0未満なので細かい不具合がありますが、Flutterになれたエンジニアであれば迷うことなくターミナルUIアプリケーションを実装できそうです。
LLMのフロントエンド、非エンジニア向け社内ツール(でもGUIは面倒)といったような、ちょっとしたUIが必要な場面で使うのに良いかもしれません。
最後に
株式会社ボトルキューブではFlutterを使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。
また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。
