TUI アプリの作り方

ターミナル上でインタラクティブに操作できる「TUI(Text User Interface)」アプリを NagiScript で作ります。ネイティブバイナリとして単体で動く、キーボード操作のツールを作るのがゴールです。


TUI の基本構造

TUI アプリの骨組みは「描画 → 入力待ち → 状態更新 → 再描画」のループです。NagiScript ではこのループを while で書き、画面更新は ANSI エスケープシーケンスの print 出力で行います。

現行仕様(v0.2.0)

文字列の + 連結や数値の文字列化(str())は未実装です。ANSI シーケンスは "\x1b[" のような文字列リテラルを print(...) で出力し、数値は println(n) でそのまま表示します。const も存在しないため、定数は val を使います。

loop.ngs
fn main() {
    var frame = 0
    print("\x1b[?25l")     // カーソル非表示
    while true {
        print("\x1b[2J")   // 画面クリア
        print("\x1b[H")    // カーソルを(1,1)へ
        println(frame)     // フレーム番号を描画
        println("---")
        println("press q to quit")
        frame = frame + 1
        if frame >= 120 { break }
    }
    print("\x1b[?25h")     // カーソル表示
}
ロードマップ

「const ESC = "\x1b[" のような定数」や「ESC + "2J" のような文字列組み立て(+ 連結・str() 変換)は現行では未実装です。現在はリテラル全体を1つの文字列として print / println で出力してください。const + str() は将来の予定です。


画面の制御(ANSI エスケープ)

よく使う制御コードは以下のとおりです。print でそのまま出力できます。

コード 効果
\x1b[2J 画面クリア
\x1b[H カーソルを左上へ移動
\x1b[<行>;<列>H 指定位置へ移動
\x1b[<n>A/B/C/D カーソルを n 移動(上/下/右/左)
\x1b[?25l / ?25h カーソル非表示 / 表示
\x1b[0m 装飾リセット
progress.ngs
fn main() {
    var i = 0
    while i <= 100 {
        print("\r")            // \r で行先頭に戻る
        println(i)             // 進捗を数値で表示
        i = i + 5
    }
    println("done")
}
ロードマップ

進捗バー文字列を組み立てる progress_bar()(下記の将来案)は文字列の + 連結と str() 変換が必要なため、現行では動きません。] " や % を含む完成形進捗バーは、連結と str() が実装された将来バージョンで使用できます。現在は数値を println で出力する簡易表示にしてください。

// 将来案(未実装)
// fn progress_bar(width: i32, done: i32, total: i32) string {
//     var bar = "["
//     val filled = (done * width) / total
//     var i = 0
//     while i < width {
//         if i < filled { bar = bar + "#" } else { bar = bar + "-" }
//         i = i + 1
//     }
//     bar = bar + "] " + str((done * 100) / total) + "%"
//     return bar
// }

キーボード入力を受け取る

ユーザーがキーを押した瞬間に反応させるには、端末を raw モードにする必要があります。これは C 標準ライブラリの termios を extern で呼び出すと実現できます。C との相互運用の詳細は C言語との相互運用 を参照してください。

rawmode.ngs
// termios を extern "C" で宣言(libc にリンク)
extern "C" fn tcgetattr(fd: i32, termp: *u8) -> i32
extern "C" fn tcsetattr(fd: i32, actions: i32, termp: *u8) -> i32
extern "C" fn getchar() -> i32

fn raw_mode_enter() {
    var term: [64]u8 = [0; 64]     // struct termios の配置領域
    tcgetattr(0, &term[0] as *u8)
    // ECHO ビットを落として反映(byte 位置は 実装依存)
    var next = term[12] & 247      // ECHO(8) の逆ビット
    term[12] = next
    tcsetattr(0, 0, &term[0] as *u8)
}

fn read_key() -> i32 {
    return getchar()
}
注意(現行仕様)

ポインタは *u8 形式で、アドレスを取るのは &x as *u8、取り出すのは unsafe { *ptr } です。*mut / *const 記法は存在しません。struct termios の正確なレイアウトは取得していません。上記は概念的な raw モード実装であり、実際にはプラットフォームの termios 定義に合わせる必要があります。println(frame) と同じく出力は print / println を使います。

シンプルな対話アプリであれば、raw モードを使わず「1行の入力で Enter 確定」でも十分動きます。

1行入力で動くバージョン

現行仕様(v0.2.0)

行組み立てには文字列の + 連結が必要なため、現行では getchar() で押されたキーのコード値をそのまま表示するバージョンを示します。Enter(10 か 13)で終了します。

input.ngs
extern "C" fn getchar() -> i32

fn read_key() -> i32 {
    while true {
        val c = getchar()
        if c >= 0 { return c }
    }
}

fn main() {
    println("press keys; Enter to quit")
    while true {
        val k = read_key()
        println(k)          // 押されたキーの文字コードを表示
        if k == 10 or k == 13 { break }   // LF / CR
    }
    println("bye!")
}
ロードマップ

「プロンプトを表示して1行の文字列を組み立てる」(io.print(prompt) → line + str(c as u8) → "Hello, " + name + "!")は、文字列連結と str() が未実装のため現行では動きません。単一引数の print / println とキーコード表示で代用してください。


タスクマネージャー TUI を作る

現行仕様(v0.2.0)

List<T> は LLVM リンクに失敗する既知バグ、Rc は .get() の戻り値が不確実、std:fs / Result 型は未実装です。そのため実用的な「タスクリスト管理」アプリは固定配列 [Task; 16] + 件数カウンターで状態を持ち、保存・読み込みは行わないメモリ内版として示します。

tasks.ngs
extern "C" fn getchar() -> i32

struct Task {
    title: string
    done: bool
}

var tasks: [Task; 16]
var task_count: i32 = 0

fn add_task(title: string) {
    if task_count < 16 {
        tasks[task_count] = Task { title: title, done: false }
        task_count = task_count + 1
    }
}

fn toggle_task(index: i32) {
    if index >= 0 and index < task_count {
        tasks[index].done = not tasks[index].done
    }
}

fn main() {
    // 入力ループ:
    //   a : タスク追加(id のみ増える)
    //   t : 最新タスクをトグル
    //   q : 終了
    print("\x1b[?25l")           // カーソル非表示
    while true {
        print("\x1b[2J")         // クリア
        print("\x1b[H")          // 左上へ
        println("=== Task Manager ===")
        var i = 0
        while i < task_count {
            val t = tasks[i]
            if t.done {
                println("[x]")
            } else {
                println("[ ]")
            }
            i = i + 1
        }
        println("---")
        println("[a]dd  [t]oggle  [q]uit")
        val key = read_key()
        if key == 113 { break }            // q
        if key == 97 { add_task("task") }  // a
        if key == 116 { toggle_task(task_count - 1) }  // t
    }
    print("\x1b[?25h")           // カーソル表示
    println("bye!")
    println(task_count)          // 件数を表示
}

fn read_key() -> i32 {
    return getchar()
}

ビルドと実行

bash
# ネイティブバイナリにコンパイル(termios は libc にリンク)
nagiscript build tasks.ngs -o task-manager

# 実行
./task-manager

単一バイナリで動くので、インストーラーやコンテナなしでそのまま配布できます。


発展させるときのヒント

  1. ウィンドウ描画を抽象化 — 位置指定(\x1b[行;列H)を使うと、複数ペインを自由に並べられます
  2. 状態は struct でまとめる — AppState を1つの構造体にすると再描画・永続化が楽になります
  3. 永久化は将来の std:fs で — 現行ではファイル保存・読み込み(std:fs)は未実装です。保存機能はロードマップとして設計に残し、当面はメモリ内で動かします
  4. 色を使う — \x1b[31m(赤)等を組み合わせて選択中カーソルや完了タスクを強調できます