ターミナルUI

画面を所有し、スタイル付きのフレームを描画し、自身のレイアウトの枠で囲まれた領域に別のプロセスをホストするターミナルアプリケーションを構築します。

構築するもの

シェルプロセスがターミナルホスト上で動作し、物理ターミナルのプレゼンテーションリースを取得します。ヘッダー、ステータスバー、枠線を描画します。その枠の内側に2つ目のプロセスをホストし、そのプロセスは擬似ターミナル経由でインタラクティブなBashを実行します。

physical terminal -> shell surface -> viewport -> child process -> PTY proxy

シェルは子プロセスがどこに表示されるかを決定し、入力を子の座標系へ変換します。子は通常のターミナルポートを見ているだけで、自分が枠に収められていることを知りません。

プロジェクト構造

tty-app/
├── wippy.lock
└── src/
    ├── _index.yaml
    ├── shell.lua
    └── child.lua
mkdir tty-app && cd tty-app
mkdir src

ステップ1: エントリ定義

src/_index.yamlを作成:

version: "1.0"
namespace: app

entries:
  - name: policy
    kind: security.policy
    policy:
      actions:
        - process.context
        - process.spawn
        - process.spawn.monitored
        - process.host
        - process.terminate
        - exec.get
        - exec.run
      resources: "*"
      effect: allow

  # 子プロセスを実行する
  - name: workers
    kind: process.host
    host:
      workers: 2
    lifecycle:
      auto_start: true

  # 物理ターミナルを所有する
  - name: terminal
    kind: terminal.host
    hide_logs: true
    lifecycle:
      auto_start: true

  - name: exec
    kind: exec.native

  - name: child
    kind: process.lua
    source: file://child.lua
    method: main
    modules: [channel, exec, tty]
    security:
      policies: [app:policy]

  - name: shell
    kind: process.lua
    source: file://shell.lua
    method: main
    modules: [channel, process, time, tty]
    meta:
      command:
        name: shell
        short: Run the terminal shell
        security:
          actor: {id: app:shell}
          policies: [app:policy]
hide_logs: trueはログ出力をターミナルではなくイベントバスへリダイレクトします。サーフェスを所有するプロセスは完全なフレームを発行するため、同じターミナルへ書き込む他のものはそれを壊してしまいます。

ステップ2: 入力ループ

src/shell.luaを作成します。先にイベントを購読し、その後で入力の配信を開始することで、コンシューマーが存在する前にイベントが到着しないようにします:

local tty = require("tty")

local function main()
    local events = assert(tty.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))

    while true do
        local ev = events:receive()
        if not ev then break end

        if ev.type == "resize" then
            width, height = ev.width, ev.height
        elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
            break
        end
    end

    assert(tty.stop())
end

return {main = main}

イベントはtypeで判別されるレコードです。印字可能なキーではkey_type"runes"keyがそのテキストを保持します。名前付きキーではkey_typekeyの両方が名前("enter""backspace""up")を保持します。マウスイベントの座標は1始まりです。

ステップ3: フレームの描画

Surfaceはターミナルのプレゼンテーションリースです。完全な行の配列を受け取り、直前のフレームとの差分を取ります。Canvasは、自前のターミナル制御シーケンスを一切出力せずに、スタイル付きテキストからそれらの行を構成します。

shell.luaの先頭にスタイルとパディング用のヘルパーを追加します:

local tty = require("tty")

local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local status_style = tty.style():foreground("#a3be8c")
local prompt_style = tty.style():foreground("#88c0d0")

local function fit(text, width)
    local clipped = tty.text.truncate(text, width)
    return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end

tty.text.truncatetty.text.widthはANSIを認識するため、スタイル付きテキストはバイト数ではなく印字可能なセル数で計測され、切り詰められます。

次にサーフェスを開き、ヘッダー、スクロール可能な本文、ステータスバー、そして最終行に固定された入力行を持つフレームを発行します:

local function main()
    local events = assert(tty.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local surface = assert(tty.surface({
        alternate_screen = true,
        hide_cursor = true,
        synchronized_output = true,
    }))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))
    local canvas = tty.canvas(width, height)

    local lines, scroll, input = {}, 0, ""

    local function draw()
        local body_height = height - 3
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy tui — Ctrl+Q to quit ", width)))

        local first = math.max(1, #lines - body_height + 1 - scroll)
        for row = 1, body_height do
            local line = lines[first + row - 1]
            if line then
                canvas:put(2, row + 1, line, width - 2)
            end
        end

        canvas:put(1, height - 1, status_style:render(fit(
            string.format(" %d lines   scroll %d   %dx%d", #lines, scroll, width, height), width)))
        canvas:put(1, height, prompt_style:render("> ") .. input)

        assert(surface:present(canvas:rows(), {
            cursor = {x = math.min(width, 3 + tty.text.width(input)), y = height, visible = true},
        }))
    end

    draw()
    while true do
        local ev = events:receive()
        if not ev then break end

        if ev.type == "resize" then
            width = math.max(20, ev.width)
            height = math.max(8, ev.height)
            canvas = tty.canvas(width, height)
            surface:invalidate()
        elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
            break
        elseif ev.type == "key" and ev.action == "press" then
            if ev.key == "enter" then
                lines[#lines + 1] = "> " .. input
                input, scroll = "", 0
            elseif ev.key == "backspace" then
                input = input:sub(1, -2)
            elseif ev.key_type == "runes" and not ev.ctrl and not ev.alt then
                input = input .. ev.key
            end
        elseif ev.type == "mouse" and ev.action == "wheel" then
            if ev.button == "wheel_up" then
                scroll = scroll + 1
            elseif ev.button == "wheel_down" then
                scroll = math.max(0, scroll - 1)
            end
        end
        draw()
    end

    assert(surface:close())
    assert(tty.stop())
end

presentは毎回フレーム全体を発行します。バックエンドは変化した行だけを書き込み、rowschanged_rowsbytes_writtenを報告します。invalidate()はその比較状態を忘れさせます。外側のターミナルが自分の知らないうちにリサイズされた後に必要となる動作です。

ステップ4: 子プロセスのホスト

Viewportは仮想のターミナルポートです。シェルはこれを作成し、子にそのグラントを渡し、子が発行したフレームを読み戻します。

レイアウトの本文を枠付きの領域に置き換え、その内側にビューポートの行を配置します:

local channel = require("channel")
local process = require("process")
local tty = require("tty")

local BODY_X, BODY_Y = 2, 3            -- 枠の内側の左上セル
local CHROME_ROWS = 5                  -- ヘッダー、枠線2行、ステータス、ヒント

local border_style = tty.style():foreground("#4c566a")
local hint_style = tty.style():faint()

ビューポートを作成し、グラント付きで子をスポーンし、更新のウォーターマークを購読します:

    local inner_width = math.max(1, width - 2)
    local inner_height = math.max(1, height - CHROME_ROWS)

    local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
    local updates = assert(viewport:updates())
    local child = assert(process.with_options({terminal = assert(viewport:grant())})
        :spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))

グラントは一度きりです。受け入れによって消費されます。起動が拒否された場合グラントは未解決のまま残り、ターミナルをアタッチできないホストは、オプションを黙って破棄するのではなくスポーンを拒否します。

枠線は自分で描画し、put_rowsで子の行をその内側に配置します。put_rowsは何かを描画する前にすべての行を検証します:

    local function draw()
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
        canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
        for row = 1, inner_height do
            canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
            canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
        end
        canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
        canvas:put(1, BODY_Y + inner_height,
            border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
        canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
        canvas:put(1, height, hint_style:render(fit(
            string.format(" child viewport %dx%d", inner_width, inner_height), width)))

        local cursor = {x = 1, y = height, visible = false}
        if frame.cursor then
            cursor = {
                x = math.min(width, BODY_X + frame.cursor.x - 1),
                y = math.min(height, BODY_Y + frame.cursor.y - 1),
                visible = frame.cursor.visible,
            }
        end
        assert(surface:present(canvas:rows(), {cursor = cursor}))
    end

子はビューポートへ発行します。シェルはupdatesを通じてそれを知り、snapshotで状態を読み取ります:

        if selected.channel == updates then
            local next_frame = viewport:snapshot(revision)
            if next_frame then
                frame, revision = next_frame, next_frame.revision
                if #frame.rows > 0 then ready = true end
                draw()
            end
        end

更新はまとめられたウォーターマークであり、イベントログではありません。遅いシェルは最新の1つだけを受け取り、実際の行を得るにはsnapshot()を呼ばなければなりません。直前のリビジョンを渡すと、何も変化していない場合snapshotnilを返します。新しいリビジョンは子が描画したことを意味しません。viewport:resizeもリビジョンを進めますし、最初のフレームまでスナップショットは行を持ちません。そのためreadyはリビジョンではなくrowsを基準にしています。

入力はviewport:sendを通じて逆方向へ流れます。キーイベントはそのまま渡されます。マウスの座標は子の1始まりの空間へ移す必要があり、領域外のイベントは破棄されます:

    local function translate(event)
        if event.type ~= "mouse" then
            return event
        end
        local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
        if x < 1 or y < 1 or x > inner_width or y > inner_height then
            return nil
        end
        return {
            type = "mouse", action = event.action, button = event.button,
            x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
        }
    end

sendは生成側がtty.start()を呼び出していることを要求するため、シェルは何かを転送する前に最初のフレームを待ちます。それを追跡するのがreadyフラグです。

ステップ5: 子プロセス

src/child.luaを作成します。子は通常のターミナルポートを受け取るため、同じttyモジュールを使います。ただし自分で描画する代わりに、そのポートをPTYに支えられたプロセスへ渡します。

local channel = require("channel")
local exec = require("exec")
local tty = require("tty")

local function main(command)
    local events = assert(tty.events())
    assert(tty.start())

    local executor = assert(exec.get("app:exec"))
    local proc = assert(executor:exec(command or "/bin/bash --noprofile --norc", {
        pty = {term = "xterm-256color"},
    }))
    local session = assert(proc:attach_terminal())
    local done = session:done()

    while true do
        local selected = channel.select({
            events:case_receive(),
            done:case_receive(),
        })
        if not selected.ok or selected.channel == done then break end

        local event = selected.value
        if event.type == "close" then break end
        assert(session:send(event))
    end

    assert(session:close())
    assert(executor:release())
    assert(tty.stop())
end

return {main = main}

attach_terminal()は未起動のPTYプロセスを消費し、それを所有するTerminalSessionを返します。PTYのエミュレーション、入力のエンコード、リサイズ、終了、後始末を担います。セッションは子が保持しているポート上にサーフェスを開くため、子がターミナルホスト上で動いていてもビューポート内で動いていても、同じコードが機能します。

子が転送するすべて(キー、マウス、ペースト、フォーカス、そしてシェルが生成するresizeイベント)は、Bashにとってのターミナル入力になります。closeイベントは、シェルによる正常終了の要求です。

ステップ6: リサイズ、シャットダウン、後始末

外側のターミナルのリサイズは3つのものを変化させます。シェル自身のジオメトリ、ビューポートのジオメトリ、そして画面に何が表示されているかについてのバックエンドの認識です。

            if event.type == "resize" then
                width = math.max(20, math.floor(event.width))
                height = math.max(8, math.floor(event.height))
                inner_width = math.max(1, width - 2)
                inner_height = math.max(1, height - CHROME_ROWS)
                canvas = tty.canvas(width, height)
                assert(viewport:resize(inner_width, inner_height))
                surface:invalidate()
                draw()
            end

viewport:resizeは閲覧者向けにリビジョンを進め、子へresizeイベントを配信します。子はそれを自身のターミナルセッションへ転送し、セッションがPTYをリサイズします。シェル側の1回の呼び出しが一番下まで届きます。

Ctrl+Qは子に停止を求め、期限を設定します。これにより応答しない子がシェルをハングさせることはありません:

            elseif event.type == "key" and event.ctrl and event.key == "q" then
                if not closing then
                    closing = true
                    status = "closing child"
                    if ready then
                        assert(viewport:send({type = "close"}))
                    else
                        assert(process.terminate(child))
                    end
                    deadline = time.after("3s")
                    draw()
                end

ループは子の終了をライフサイクルイベントで監視し、それが決して来ない場合に備えて期限チャネルも監視します:

        elseif selected.channel == lifecycle then
            local event = selected.value
            if event.kind == process.event.EXIT and event.from == child then break end
        elseif deadline and selected.channel == deadline then
            assert(process.terminate(child))
            deadline = nil

内側から外側へ順に片付けます。閲覧者をデタッチし、プレゼンテーションリースを解放し、最後に入力を停止します。

    assert(viewport:close())
    assert(surface:close())
    assert(tty.stop())

ビューポートを閉じてもデタッチされるのはその閲覧者だけで、生成側が終了させられることはありません。サーフェスを閉じると、取得したターミナルモード(代替画面とカーソル)が復元されます。

完成したシェル

src/shell.lua:

local channel = require("channel")
local process = require("process")
local time = require("time")
local tty = require("tty")

local BODY_X, BODY_Y = 2, 3
local CHROME_ROWS = 5

local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local border_style = tty.style():foreground("#4c566a")
local status_style = tty.style():foreground("#a3be8c")
local hint_style = tty.style():faint()

local function fit(text, width)
    local clipped = tty.text.truncate(text, width)
    return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end

local function main()
    local events = assert(tty.events())
    local lifecycle = assert(process.events())
    assert(tty.start())
    assert(tty.mouse(true))

    local surface = assert(tty.surface({
        alternate_screen = true,
        hide_cursor = true,
        synchronized_output = true,
    }))

    local width, height = tty.screen_size()
    width = math.max(20, math.floor(width or 80))
    height = math.max(8, math.floor(height or 24))
    local inner_width = math.max(1, width - 2)
    local inner_height = math.max(1, height - CHROME_ROWS)
    local canvas = tty.canvas(width, height)

    local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
    local updates = assert(viewport:updates())
    local child = assert(process.with_options({terminal = assert(viewport:grant())})
        :spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))

    local frame = {rows = {}}
    local revision = -1
    local ready, closing = false, false
    local status = "starting child"
    local deadline

    local function draw()
        canvas:clear()
        canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
        canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
        for row = 1, inner_height do
            canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
            canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
        end
        canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
        canvas:put(1, BODY_Y + inner_height,
            border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
        canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
        canvas:put(1, height, hint_style:render(fit(
            string.format(" child viewport %dx%d", inner_width, inner_height), width)))

        local cursor = {x = 1, y = height, visible = false}
        if frame.cursor then
            cursor = {
                x = math.min(width, BODY_X + frame.cursor.x - 1),
                y = math.min(height, BODY_Y + frame.cursor.y - 1),
                visible = frame.cursor.visible,
            }
        end
        assert(surface:present(canvas:rows(), {cursor = cursor}))
    end

    local function translate(event)
        if event.type ~= "mouse" then
            return event
        end
        local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
        if x < 1 or y < 1 or x > inner_width or y > inner_height then
            return nil
        end
        return {
            type = "mouse", action = event.action, button = event.button,
            x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
        }
    end

    draw()
    while true do
        local cases = {
            events:case_receive(),
            lifecycle:case_receive(),
            updates:case_receive(),
        }
        if deadline then
            cases[#cases + 1] = deadline:case_receive()
        end

        local selected = channel.select(cases)
        if not selected.ok then break end

        if selected.channel == updates then
            local next_frame = viewport:snapshot(revision)
            if next_frame then
                frame, revision = next_frame, next_frame.revision
                if #frame.rows > 0 then ready = true end
                if not closing then
                    status = "child running"
                end
                draw()
            end
        elseif selected.channel == lifecycle then
            local event = selected.value
            if event.kind == process.event.EXIT and event.from == child then break end
        elseif deadline and selected.channel == deadline then
            assert(process.terminate(child))
            deadline = nil
        else
            local event = selected.value
            if event.type == "resize" then
                width = math.max(20, math.floor(event.width))
                height = math.max(8, math.floor(event.height))
                inner_width = math.max(1, width - 2)
                inner_height = math.max(1, height - CHROME_ROWS)
                canvas = tty.canvas(width, height)
                assert(viewport:resize(inner_width, inner_height))
                surface:invalidate()
                draw()
            elseif event.type == "key" and event.ctrl and event.key == "q" then
                if not closing then
                    closing = true
                    status = "closing child"
                    if ready then
                        assert(viewport:send({type = "close"}))
                    else
                        assert(process.terminate(child))
                    end
                    deadline = time.after("3s")
                    draw()
                end
            elseif not closing and ready and event.type ~= "start" then
                local forwarded = translate(event)
                if forwarded then
                    assert(viewport:send(forwarded))
                end
            end
        end
    end

    assert(viewport:close())
    assert(surface:close())
    assert(tty.stop())
end

return {main = main}

実行

wippy init
wippy run shell

枠に収まったBashで普通に入力できます。矢印キー、タブ補完、htopvimのような全画面プログラムもすべて動作します。子が本物のPTYと会話しているからです。ターミナルウィンドウをリサイズすれば、枠線、ステータスバー、子のジオメトリが追従します。Ctrl+Qを押すと子が閉じ、ターミナルが復元されます。

次に進む先

  • 2つ目のビューポートを作成し、本文を2つの子で分割して、フォーカスされている方にのみ入力を転送する。
  • viewport:handle()を呼び出してハンドルを別のプロセスへ渡す。そのプロセスはtty.attach(handle)でアタッチし、同じ子を自身のレイアウト内で描画する。
  • Bashの子を、自分でサーフェスを描画するLuaプロセスに置き換える。ビューポートが両者の間の唯一の契約であるため、シェルは変更不要です。

関連項目

  • TTY — イベント、サーフェス、キャンバス、ビューポート、スタイル、テキストユーティリティ
  • コマンド実行 — PTYのオプション、attach_terminal、ターミナルセッション
  • ターミナル — ターミナルホストの設定と合成可能なターミナルモデル
  • プロセス — スポーンのオプション、モニタリング、ライフサイクルイベント
  • CLIアプリケーション — 行指向のターミナルプログラム