Lowent 매뉴얼←↑→

37 터미널 — term 과 tty

먼저 알아야 할 것

33장 글자와 부호화 · fmt 는 호출자의 버퍼에 조립하고 다음 자리를 돌려준다
36장 입출력·네트워크·시간·난수·암호 · 셈과 권위를 가른다

돌아보기

36장에서 http 파서와 net 이 다른 모듈인 까닭은 무엇이었는가?

답. 바이트를 받는 일(권위 — cap net 이 필요하다)과 그 바이트를 해석하는 일(셈 — 순수하다)이 다른 일이기 때문이다. 합치면 순수한 해석까지 권한과 효과를 끌고 다니고 시험하기 어려워진다. 이 장의 터미널 모듈도 똑같이 반으로 갈라져 있다.

이 장의 필요성과 맥락

터미널 프로그램은 두 가지를 한다. 화면에 보낼 제어 바이트를 짓고, 사용자의 키를 읽는다. 둘 다 운영체제와 닿는 부분은 아주 얇고, 나머지는 전부 계산이다. 화면을 얼마나 다시 그려야 하는지, 한글 한 글자가 몇 칸을 차지하는지, ESC [ A 가 무슨 키인지는 계산이다. 표준 라이브러리는 이 계산을 순수한 term·tty 파싱으로, 운영체제에 닿는 부분을 cap tty 가 필요한 세 내장 op 으로 갈랐다. 라이브러리 둘러보기의 마지막 장으로, 이 가르기가 실제로 무엇을 사는지 본다.

이 장이 끝나면

term 이 화면에 아무것도 쓰지 않고 호출자의 버퍼에 ANSI 제어 바이트를 조립한다는 것, 화면 diff 로 바뀐 칸만 다시 그리는 방식을 익힌다. 표시 폭과 글자 묶음(grapheme cluster)이 바이트 수와 다르다는 것을 확인한다. tty.parse_key 로 키를 순수하게 해석하고, raw 모드를 켜고 끄는 cap tty 내장 op 을 쓸 때 반드시 되돌려야 하는 이유도 보게 된다.

이 장에서 답할 질문

  1. 칸의 폭은 터미널마다 다르지 않은가?

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]

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]

터미널에서 줄을 맞추거나 잘라낼 때 바이트나 코드포인트로 세면 한글과 이모지에서 칸이 어긋난다. 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_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 traw 모드 · 키 바이트 읽기 · 화면 크기cap tty — raw 모드는 반드시 되돌린다
term.goto buf 0 2 50 부터 센 행·열 — 바이트는 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 모드는 반드시 되돌린다.