터미널 UI
화면을 소유하고, 스타일이 적용된 프레임을 그리며, 자체 레이아웃의 테두리 영역 안에 다른 프로세스를 호스팅하는 터미널 애플리케이션을 만듭니다.
무엇을 구축할 것인가
셸 프로세스가 터미널 호스트에서 실행되면서 물리적 터미널의 표현 리스(presentation lease)를 가져갑니다. 헤더, 상태 표시줄, 테두리를 그립니다. 그 테두리 안에서 두 번째 프로세스를 호스팅하며, 이 프로세스는 의사 터미널(pseudo-terminal)을 통해 대화형 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_type과 key 모두 그 이름("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.truncate와 tty.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는 프레임 전체를 발행합니다. 백엔드는 변경된 행만 쓰고 rows, changed_rows, bytes_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 -- 헤더, 테두리 두 행, 상태, 힌트
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
업데이트는 이벤트 로그가 아니라 병합된 워터마크입니다. 느린 셸은 가장 최신 것 하나만 받으며, 실제 행을 얻으려면 snapshot()을 호출해야 합니다. 마지막 리비전을 전달하면 변경이 없을 때 snapshot이 nil을 반환합니다. 새 리비전이 자식이 그렸다는 뜻은 아닙니다. 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단계: 크기 조정, 종료, 정리
바깥 터미널의 크기 조정은 세 가지를 바꿉니다. 셸 자신의 기하 구조, 뷰포트의 기하 구조, 그리고 화면에 이미 무엇이 있는지에 대한 백엔드의 인식입니다.
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 크기를 조정합니다. 셸 쪽 호출 하나가 끝까지 도달합니다.
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에 평소처럼 입력하십시오. 화살표 키, 탭 완성, 그리고 htop이나 vim 같은 전체 화면 프로그램이 모두 동작합니다. 자식이 실제 PTY와 대화하고 있기 때문입니다. 터미널 창의 크기를 바꾸면 테두리, 상태 표시줄, 자식의 기하 구조가 따라옵니다. Ctrl+Q를 누르면 자식이 닫히고 터미널이 복원됩니다.
다음으로 할 것
- 두 번째 뷰포트를 만들어 본문을 두 자식이 나눠 쓰게 하고, 포커스된 쪽에만 입력을 전달해 보십시오.
viewport:handle()을 호출해 그 핸들을 다른 프로세스에 넘기면, 그 프로세스가tty.attach(handle)로 붙어 같은 자식을 자신의 레이아웃에 렌더링합니다.- Bash 자식을 자체 서피스를 그리는 Lua 프로세스로 교체해 보십시오. 뷰포트가 둘 사이의 유일한 계약이므로 셸은 바뀌지 않습니다.
참고
- TTY — 이벤트, 서피스, 캔버스, 뷰포트, 스타일, 텍스트 유틸리티
- 명령 실행 — PTY 옵션,
attach_terminal, 터미널 세션 - Terminal — 터미널 호스트 구성과 조합 가능한 터미널 모델
- 프로세스 — 스폰 옵션, 모니터링, 라이프사이클 이벤트
- CLI 애플리케이션 — 줄 단위 터미널 프로그램