37 터미널 — term 과 tty
먼저 알아야 할 것
fmt 는 호출자의 버퍼에 조립하고 다음 자리를 돌려준다돌아보기
36장에서 http 파서와 net 이 다른 모듈인 까닭은 무엇이었는가?
답. 바이트를 받는 일(권위 — cap net 이 필요하다)과 그 바이트를 해석하는 일(셈 — 순수하다)이 다른 일이기 때문이다. 합치면 순수한 해석까지 권한과 효과를 끌고 다니고 시험하기 어려워진다. 이 장의 터미널 모듈도 똑같이 반으로 갈라져 있다.
이 장의 필요성과 맥락
ESC [ A 가 무슨 키인지는 계산이다. 표준 라이브러리는 이 계산을 순수한 term·tty 파싱으로, 운영체제에 닿는 부분을 cap tty 가 필요한 세 내장 op 으로 갈랐다. 라이브러리 둘러보기의 마지막 장으로, 이 가르기가 실제로 무엇을 사는지 본다.이 장이 끝나면
term 이 화면에 아무것도 쓰지 않고 호출자의 버퍼에 ANSI 제어 바이트를 조립한다는 것, 화면 diff 로 바뀐 칸만 다시 그리는 방식을 익힌다. 표시 폭과 글자 묶음(grapheme cluster)이 바이트 수와 다르다는 것을 확인한다. tty.parse_key 로 키를 순수하게 해석하고, raw 모드를 켜고 끄는 cap tty 내장 op 을 쓸 때 반드시 되돌려야 하는 이유도 보게 된다.이 장에서 답할 질문
- 칸의 폭은 터미널마다 다르지 않은가?
37.1 term 은 화면에 쓰지 않는다#
term 의 op 은 전부 effects none 이다. 커서를 옮기고(goto), 색을 바꾸고(sgr·color256), 화면을 지우는(clear) 제어 바이트를 호출자의 버퍼에 조립하고 다음 쓸 자리를 돌려줄 뿐이다. 실제로 화면에 내보내는 일은 outbuf(36장)가 한다.
이것을 모르면 “코드는 도는데 화면에 아무것도 안 뜬다” 는 막다른 길에 빠진다. 그러나 이 가르기 덕분에 화면 코드 전부를 화면 없이 시험할 수 있다. 조립한 바이트를 비교하면 된다.
examples/ch37/assemble.low
module assemble .
rem run: paint [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
use term .
rem 화면에 쓰지 않는다 — buf 에 제어 바이트를 이어 붙이고, 쓴 바이트 수를 돌려준다
proc paint input buf mut slice u8 . . output u64 . effects state .
do
rem 0 부터 센 2 행 5 열로 커서를 옮긴다 — 터미널은 1 부터 세므로 ESC [ 3 ; 6 H
let p1 option u64 be term.goto buf 0 2 5 .
guard is_some p1 . else return 0 .
rem 굵게(1) — ESC [ 1 m. 앞 op 이 돌려준 자리에서 이어 쓴다
let p2 option u64 be term.sgr buf (some_value p1) 1 .
guard is_some p2 . else return 0 .
let at u64 be some_value p2 .
set (index buf at) 104 .
set (index buf (add at 1)) 105 .
rem 속성을 되돌린다(0) — ESC [ 0 m
let p3 option u64 be term.sgr buf (add at 2) 0 .
guard is_some p3 . else return 0 .
return some_value p3 .
end
실행 결과
$ lowentc --run paint assemble.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
paint([27,91,51,59,54,72,27,91,49,109,104,105,27,91,48,109,0,0,0,0,0,0,0,0]) = 16
arg0 (written) = [27,91,51,59,54,72,27,91,49,109,104,105,27,91,48,109,0,0,0,0,0,0,0,0]
term.goto buf 0 2 5는buf의 0 번 자리부터 커서 이동 바이트를 쓰고, 다음에 쓸 자리 6 을option으로 돌려준다. 버퍼가 모자라면 아무것도 쓰지 않고none이다. 반쯤 쓴 제어 바이트는 화면을 망가뜨리기 때문이다.- 행과 열은 0 부터 센다. 터미널의 약속(ANSI)은 1 부터 세므로
goto가 1 을 더해ESC [ 3 ; 6 H(27 91 51 59 54 72)를 쓴다. 배열 색인과 같은 기준을 쓰게 하려는 선택이다. term.sgr buf 6 1은 굵게를 켜는ESC [ 1 m를, 마지막term.sgr … 0은 속성을 되돌리는ESC [ 0 m를 쓴다. 그 사이의 104·105 가 글자hi다.- 모든 op 이 “다음 자리” 를 돌려주므로 앞 op 의 답을 다음 op 의
pos로 잇는다. 쓴 바이트는 모두 16 개이고, VM 이 보여 주는buf가 그 바이트 그대로다.
37.2 바뀐 칸만 다시 그린다#
examples/ch37/diffing.low
module diffing .
rem run: main
use term .
proc main input al cap allocator . output u8 . effects alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 64 .
guard is_some g . else return 255 .
let out mut slice u8 be some_value g .
let same option u64 be term.diff "hello " "hello " 8 out 0 .
guard is_some same . else return 254 .
let changed option u64 be term.diff "hello " "help! " 8 out 0 .
guard is_some changed . else return 253 .
return narrow u8 (add (mul (some_value same) 100) (some_value changed)) .
end
실행 결과
$ lowentc --run main diffing.low
main() = 8
term.diff prev next w out pos 는 직전 화면 prev 와 새 화면 next 를 비교해 바뀐 연속 구간만 “그 자리로 가서 이것을 써라” 로 out 에 쓴다. 두 화면이 같으면 0 바이트다 — 0 바이트가 곧 “화면을 건드리지 마라” 는 답이다. “hello” 가 “help!” 로 바뀌면 커서 이동과 바뀐 두 글자로 8 바이트다. 매번 화면 전체를 다시 그리면 깜빡이고 원격 접속에서는 전송량도 크다.
37.3 칸의 수는 바이트의 수가 아니다#
examples/ch37/widths.low
module widths .
rem run: columns [104,105]
rem run: columns [236,149,136,235,133,149]
rem run: emoji_width 128512
rem run: clusters [101,204,129,120]
use term .
proc columns input row slice u8 . output u64 . effects none .
do
let w option u64 be term.row_width row .
guard is_some w . else return 999 .
return some_value w .
end
proc emoji_width input cp u64 . output u64 . effects none .
do
return term.cp_width cp .
end
proc clusters input row slice u8 . output u64 . effects none .
do
let n option u64 be term.row_clusters row .
guard is_some n . else return 999 .
return some_value n .
end
실행 결과
$ lowentc --run columns widths.low [104,105]
columns([104,105]) = 2
arg0 (written) = [104,105]
$ lowentc --run columns widths.low [236,149,136,235,133,149]
columns([236,149,136,235,133,149]) = 4
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run emoji_width widths.low 128512
emoji_width(128512) = 2
$ lowentc --run clusters widths.low [101,204,129,120]
clusters([101,204,129,120]) = 2
arg0 (written) = [101,204,129,120]
hi는 2 바이트, 2 칸이다.- “안녕” 은 6 바이트이지만 한글은 전각이라 4 칸이다.
- 웃는 얼굴 이모지(U+1F600)는 2 칸이다.
e다음에 결합용 악센트(U+0301)가 오고x가 오는 4 바이트는 사람이 보기에 글자 두 개(é, x)다.row_clusters가 그 묶음을 센다.
터미널에서 줄을 맞추거나 잘라낼 때 바이트나 코드포인트로 세면 한글과 이모지에서 칸이 어긋난다. row_width·fit_width·cluster_len 이 칸과 글자 묶음으로 센다. 폭 표는 unicode 모듈처럼 유니코드 자료에서 뽑았다(33장).
문. 칸의 폭은 터미널마다 다르지 않은가?
답. 다르다. 동아시아의 “모호한 폭” 글자나 새 이모지를 터미널마다 다르게 그린다. term 은 유니코드 표준의 표를 따르고, 그 선택과 한계를 모듈 문서에 적어 둔다. 표시 폭은 틀려도 1 이라는 쓸 만한 기본값이 있어서 조금 어긋나도 화면이 무너지지는 않지만, 글자인지를 판정하는 unicode 의 표는 그런 기본값이 없어 기계적으로 뽑는 것이 필수였다.
37.4 키를 읽는 것은 계산이다#
examples/ch37/keys.low
module keys .
rem run: first_key [27,91,65,113]
rem run: first_key [113]
use tty .
proc first_key input buf slice u8 . output u64 . effects none .
do
let k option u64 be tty.parse_key buf 0 .
guard is_some k . else return 0 .
let code u64 be tty.key_of (some_value k) .
if eq code (tty.key_up) . do
return 1 .
end
if tty.is_char code . do
return 2 .
end
return 3 .
end
실행 결과
$ lowentc --run first_key keys.low [27,91,65,113]
first_key([27,91,65,113]) = 1
arg0 (written) = [27,91,65,113]
$ lowentc --run first_key keys.low [113]
first_key([113]) = 2
arg0 (written) = [113]
터미널이 방향키 위를 보내는 것은 ESC [ A 세 바이트다. tty.parse_key buf 0 은 그 바이트를 키 코드와 소비한 길이를 담은 값 하나로 읽는다. tty.key_of 로 키 코드를, tty.len_of 로 길이를 꺼낸다. 평범한 글자는 그 바이트 값(1 … 255) 그대로이고, 특수키는 1000 위에 둔다(key_up 은 1001). 그래서 “이건 글자다” 와 “이건 방향키다” 를 한 값으로 구별한다.
바이트열을 키로 읽는 것은 입출력이 아니라 계산이라 effects none 이고, 화면 없이 VM·네이티브 대조로 검증된다. 모르는 시퀀스나 모자란 바이트는 none 이다.
37.5 raw 모드는 권한이다#
운영체제와 닿는 부분은 세 내장 op 뿐이다. tty_raw t on(raw 모드 들어가기·나오기), tty_read t buf(키 바이트 읽기), tty_size t(화면 크기). 모두 cap tty 를 첫 인자로 받는다.
examples/ch37/rawmode.low
module rawmode .
use tty .
proc main input t cap tty . input al cap allocator . output u8 . effects alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some g . else return 1 .
let buf mut slice u8 be some_value g .
guard tty_raw t true . else return 1 .
var going bool be true .
var last u64 be 0 .
while going . do
let n option u64 be tty_read t buf .
guard is_some n . else do
set going false .
continue .
end
let p option u64 be tty.parse_key (subslice buf 0 (some_value n)) 0 .
if is_some p . do
set last (tty.key_of (some_value p)) .
if eq last 113 . do
set going false .
end
end
end
let restored bool be tty_raw t false .
return narrow_sat u8 last .
end
실행 결과
$ lowentc --check rawmode.low
== check: ok ==
- 버퍼를 raw 모드에 들어가기 전에 받는다. 들어간 뒤에 할당이 실패해 돌아가면 터미널이 raw 모드로 남는다.
tty_read로 읽은 바이트를tty.parse_key로 해석하고,q를 누르면 끝낸다.- 마지막에
tty_raw t false로 반드시 되돌린다.
raw 모드는 사용자의 터미널 설정을 바꾼다. 프로그램이 죽어도 그 변경은 남아서, 사용자의 셸에서 입력이 보이지 않고 줄바꿈이 먹지 않는다. 그런 권한이 주변에 떠돌면 아무 라이브러리나 남의 셸을 망가뜨릴 수 있다. 그래서 들고 있는 자만 행사한다. 이 예제는 키 입력을 기다리므로 검증 스크립트는 검사만 한다.
흔한 오해. raw 모드를 되돌리는 코드도 소유로 강제하면 된다
tty_raw 는 불리언을 돌려주는 내장 op 이고, “raw 모드에 들어가 있음” 을 나타내는 소유 값이 없다. 그래서 되돌리기를 잊어도 번역이 막지 않는다. outbuf 와 files 가 완결을 소유로 강제하는 것과 대조되는 자리이고, 지금은 사람이 기억해야 하는 규율이다. 모듈 문서도 이 점을 굵게 경고한다.37.6 무엇을 아직 안 지었나#
tty 모듈 문서가 스스로 적은 목록이다 — 마우스 보고, 붙여 넣기 구분(bracketed paste), 터미널 질의 응답의 해석, 화면 크기 변경 신호(SIGWINCH) 연동. 각각 따로 지을 조각이다. 이 목록이 소스와 문서에 있으므로, 이 모듈로 편집기를 지으려는 사람은 무엇을 스스로 해야 하는지 들여오기 전에 안다.
37.7 흔한 실수#
반례. raw 모드에 들어간 뒤 되돌리지 않고 떠나는 길을 만든다
examples/ch37/mistake_rawleak.low
module mistake_rawleak .
use tty .
proc main input t cap tty . input al cap allocator . output u8 . effects alloc .
do
guard tty_raw t true . else return 1 .
rem ✘ raw 모드에 들어간 *뒤에* 할당하고, 실패하면 되돌리지 않고 떠난다
let g option mut slice u8 be alloc_bytes al capacity 32 .
guard is_some g . else return 2 .
let n option u64 be tty_read t (some_value g) .
let restored bool be tty_raw t false .
return 0 .
end
실행 결과
$ lowentc --check mistake_rawleak.low
== check: ok ==
이 코드는 번역을 통과한다(tty_raw 의 되돌리기는 소유로 강제되지 않는다). 그런데 할당이 실패하면 return 2 로 떠나면서 터미널을 raw 모드로 남긴다. 사용자의 셸에서 입력이 보이지 않고 줄바꿈이 어긋난다. 이 장의 rawmode.low 처럼 실패할 수 있는 준비(버퍼 받기)를 raw 모드에 들어가기 전에 모두 끝내고, 들어간 뒤의 모든 길이 tty_raw t false 를 지나게 짠다.
반례. 줄을 바이트 수로 잘라 화면 폭에 맞춘다
examples/ch37/mistake_bytecut.low
module mistake_bytecut .
rem run: cut_bytes [236,149,136,235,133,149]
rem run: cut_columns [236,149,136,235,133,149]
use term .
rem ✘ 앞 다섯 바이트로 자른다 --- 둘째 글자 한가운데서 잘려 올바른 글자열이 아니다
proc cut_bytes input row slice u8 . output u64 . effects none .
requires ge (len row) 5 .
do
let w option u64 be term.row_width (subslice row 0 5) .
guard is_some w . else return 999 .
return some_value w .
end
rem 칸 수로 자를 자리를 묻는다 --- 세 칸에 들어가는 가장 긴 앞부분은 "안" 의 3 바이트
proc cut_columns input row slice u8 . output u64 . effects none .
do
let n option u64 be term.fit_width row 3 .
guard is_some n . else return 999 .
return some_value n .
end
실행 결과
$ lowentc --run cut_bytes mistake_bytecut.low [236,149,136,235,133,149]
cut_bytes([236,149,136,235,133,149]) = 999
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run cut_columns mistake_bytecut.low [236,149,136,235,133,149]
cut_columns([236,149,136,235,133,149]) = 3
arg0 (written) = [236,149,136,235,133,149]
“안녕” 을 앞 다섯 바이트로 자르면 둘째 글자 한가운데서 잘려 올바른 글자열이 아니므로 row_width 가 none(999)을 준다. 화면에 그대로 내보냈다면 깨진 글자가 찍혔을 것이다. term.fit_width row 3 은 세 칸에 들어가는 가장 긴 앞부분을 바이트 길이로 알려 준다 — “안” 하나, 3 바이트. 자르기는 언제나 그 답으로 한다.
흔한 오해. 한 번 읽은 바이트에는 키 하나가 온전히 들어 있다
examples/ch37/partial_key.low
module partial_key .
rem run: first_key [27,91]
rem run: first_key [27,91,65]
use tty .
rem 한 번에 읽은 바이트가 키 하나를 다 담는다는 보장은 없다
proc first_key input buf slice u8 . output u64 . effects none .
do
let k option u64 be tty.parse_key buf 0 .
guard is_some k . else return 0 .
let code u64 be tty.key_of (some_value k) .
if eq code (tty.key_up) . do
return 1 .
end
return 3 .
end
실행 결과
$ lowentc --run first_key partial_key.low [27,91]
first_key([27,91]) = 0
arg0 (written) = [27,91]
$ lowentc --run first_key partial_key.low [27,91,65]
first_key([27,91,65]) = 1
arg0 (written) = [27,91,65]
방향키 위는 ESC [ A 세 바이트인데, 읽기가 앞 두 바이트만 가져올 수 있다. 그때 tty.parse_key 는 none(여기서 0)을 주고, 셋째 바이트까지 모이면 위(1)로 읽는다. 읽은 바이트를 버리지 말고 남은 조각을 다음 읽기 앞에 이어 붙인다. none 을 “모르는 키” 로 버리면 방향키가 가끔 사라지는 결함이 된다.
37.8 이 장의 문법 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
term.goto · term.sgr · term.clear (buf pos …) | 제어 바이트를 호출자의 버퍼에 조립 | 화면에 쓰지 않는다 — 바이트로 시험한다 |
term.diff prev next w out pos | 바뀐 구간만 다시 그리는 바이트 — 같으면 0 | 깜빡임과 전송량을 줄인다 |
term.row_width row · term.row_clusters row · term.cp_width cp | 칸 수 · 글자 묶음 수 · 코드포인트 폭 | 칸은 바이트가 아니다 |
term.fit_width row cols | 칸에 들어가는 가장 긴 앞부분의 바이트 길이 | 글자 한가운데서 자르지 않는다 |
tty.parse_key buf at · tty.key_of · tty.len_of | 키 바이트 해석(순수) — 모자라면 none | 키 읽기는 계산이다 |
tty_raw t true · tty_read t buf · tty_size t | raw 모드 · 키 바이트 읽기 · 화면 크기 | cap tty — raw 모드는 반드시 되돌린다 |
term.goto buf 0 2 5 | 0 부터 센 행·열 — 바이트는 ESC [ 3 ; 6 H | 배열 색인과 같은 기준, 터미널의 1 기준은 op 이 맞춘다 |
표 37.1 — 터미널 모듈의 모양 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
term 은 ANSI 제어 바이트를 호출자의 버퍼에 조립하는 순수 모듈이고, 화면에 내보내는 일은 outbuf 가 한다. diff 는 바뀐 칸만 다시 그리며 같으면 0 바이트다. 칸의 수는 바이트 수와 다르므로 row_width·row_clusters 로 센다. tty.parse_key 는 키 바이트를 순수하게 해석한다. 운영체제와 닿는 tty_raw·tty_read·tty_size 는 cap tty 를 받고, raw 모드는 반드시 되돌린다.