本文へ移動
Hikari 仕様

std:term/event — 端末入力イベントのデコード

標準ライブラリモジュール (index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../../language-spec.md を参照。

std:term (../term.md) は標準入力から 1 バイトを読む生 I/O を提供するが、矢印キー・マウス・修飾キー等は複数バイトのエスケープ列 (ESC [ A など) として届き、その解釈は利用側の責務だった。本モジュールはそのバイト列を 1 つの高水準イベント (Event) にデコードする層を std:term の上に載せ、TUI アプリを「1 イベントずつ受け取って処理する」形で書けるようにする。

import { next_event, mouse_on, mouse_off } := "std:term/event"
名前 効果 意味
Event 型メンバー 端末入力イベントの構造的レコード型。next_event/decode の戻り値の型 (§1)。型注釈・matches に使う
NextByte 型メンバー decode に注入する 1 バイト読取り関数の型 (term.read_key 同形)。§4
PeekByte 型メンバー decode に注入する先読み関数の型 (term.peek_key 同形)。§4
next_event next_event() Io std:term から 1 イベントを読み Future(Event) を返す。内部で read_key/peek_key を使うのでアプリはバイト読取りを配線しなくてよい
decode decode(next_byte, peek_byte) Io(渡した 2 関数の効果を透過) バイト読取り 2 関数を注入して 1 イベントを組み立てる純関数。実端末不要でテスト・上級用途に使う (§4)
mouse_on String マウス報告を有効化するエスケープ列 (press/release・全移動・SGR)。raw_mode の内側で print する。hover (ボタン非押下の移動) も報告する
mouse_off String マウス報告を無効化するエスケープ列 (mouse_on と対)
mouse_drag_on String マウス報告を有効化するエスケープ列 (press/release・ドラッグ中のみ移動・SGR)。ボタン非押下の移動 (hover) は報告しないため、ドラッグして塗る/選ぶ用途では移動イベントが激減し応答が軽い。§1.3
mouse_drag_off String マウス報告を無効化するエスケープ列 (mouse_drag_on と対)
kinds namespace kind の正規値を名前で引く定数 namespace (kinds.up == "up" 等)。§1.2
buttons namespace マウス button の正規値の定数 namespace (buttons.left 等)。§1.2
actions namespace マウス action の正規値の定数 namespace (actions.press 等)。§1.2

next_eventstd:termraw_mode ブロックの内側で使うこと。マウスイベントが要る場合は先頭で print(mouse_on) (hover 込みの全移動) または print(mouse_drag_on) (ドラッグ中のみ移動)、終了時に対の print(mouse_off) / print(mouse_drag_off) する (使い分けは §1.3)。ANSI 出力 (代替画面・カーソル制御・描画) は screen.md を参照。

1. Event

Eventenum である。キー入力・マウス報告・入力終端は運ぶ情報が違うので、tag で分ける。修飾キーは kind に畳み込まず独立した ctrl/shift/alt の Bool スロットで表す (crossterm の KeyEvent { code, modifiers } / tcell の EventKey { Key, Rune, Modifiers } と同型)。

本モジュールは Event型メンバーとして export する (.hikatype/enum と同じ機構、../../language-spec.md §13.4・§17.4。std:httpRequest/Response/Handlerstd:timeInstant 等と対称)。import ev := "std:term/event" のもとで ev.Event と修飾参照するか、import { next_event, Event } := "std:term/event" で名前を取り出して型注釈・match に使える。

enum Event := OneOf(
  Key(KeyEvent)
  Mouse(MouseEvent)
  Eof
)

type KeyEvent := { kind: String, char: String, ctrl: Bool, shift: Bool, alt: Bool |}
type MouseEvent := { button: String, action: String, col: Int, row: Int, ctrl: Bool, shift: Bool, alt: Bool |}

位置とボタンを持つのはマウス報告だけである。 キーイベントに col は無いので、Mouse に分岐しないと読めない。宣言した型のメンバー集合が受け手から触れる範囲になる (../../language-spec.md §17.2) 以上、共通の型に button/action/col/row を載せて「kind == "mouse" のときだけ意味を持つ」と散文で断るやり方は採らない。

Eof は payload を持たない。 入力終端は押されたキーではないので、kind の一つとして扱わない。

  • KeyEvent.char: kind"char"/"ctrl"/"f" のとき意味を持つ文字列。それ以外の名前付きキーでは ""
  • ctrl/shift/alt: バイト列から検出できた範囲true (§3)。検出できない組合せは false。どちらの tag も持つ
  • MouseEvent.button: "left" / "middle" / "right" / "none" / "wheel_up" / "wheel_down"
  • MouseEvent.action: "press" / "release" / "move"
  • MouseEvent.col/row: 1 始まりの列・行 (SGR 報告の値そのまま)
match ev {
  Key(k) => handle_key(k.kind, k.char)
  Mouse(m) => handle_click(m.col, m.row)
  Eof => stop()
}

1.1 KeyEvent.kind の一覧 (閉じた集合)

マウス報告と入力終端は kind ではなく Event の tag (Mouse / Eof) である。

kind 由来 char
char 印字文字 (ASCII / UTF-8 多バイト) その文字
ctrl C0 制御 (Ctrl+A〜Z) 制御対象の小文字 (Ctrl-C"c")
enter CR/LF (13/10) ""
tab HT (9) ""
backspace BS/DEL (8/127) ""
esc 単独 ESC (後続が既知列でない) ""
up down left right 矢印 (CSI AD) ""
home end CSI H/FCSI 1~/4~ ""
insert delete CSI 2~/3~ ""
pageup pagedown CSI 5~/6~ ""
f ファンクションキー F1〜F12 (SS3 PSCSI n~) 番号 (F5"5")

未知のエスケープ列は原則 esc (単独 ESC) に倒し、未消費バイトは次の next_event が読む。kind は閉じた集合だが、アプリは関心のある少数のキーだけを見て残りを既定分岐で無視するのが慣用 (language-spec.md §17 の開いた kind 消費と同じ流儀)。

1.2 定数 namespace (kinds / buttons / actions)

kindbuttonaction は開いた String だが、正規値を名前で引く定数 namespace も提供する。裸の文字列リテラル ("up") の代わりに kinds.up と書くと、綴りエラー (kinds.upp) はスロット不在で評価時に失敗する ("upp" は黙って一致しないだけ)。値は文字列そのものなので == にも match パターンにも使える (match k.kind { kinds.up => … })。

namespace メンバー (= 値)
kinds char ctrl enter tab backspace esc up down left right home end insert delete pageup pagedown f (§1.1 と同一集合)
buttons left middle right none wheel_up wheel_down
actions press release move

各メンバーの値は自分の名前と同じ文字列 (kinds.up == "up"buttons.wheel_up == "wheel_up")。裸の文字列を使い続けてもよく、定数はあくまで綴り安全・発見性のための任意の別記である。

1.3 マウス報告の有効化 — mouse_onmouse_drag_on

マウスイベントを受け取るには raw_mode の内側の先頭で有効化列を print し、終了時に対の無効化列を print する。二系統ある。

有効化 / 無効化 DEC モード 移動 (actions.move) の報告範囲 用途
mouse_on / mouse_off 1000 + 1003 + 1006 全移動 (ボタン非押下の hover も含む) hover でカーソル位置を追う UI
mouse_drag_on / mouse_drag_off 1000 + 1002 + 1006 ドラッグ中のみ (ボタン押下中の移動だけ) ドラッグして塗る/選ぶ UI

いずれも 1000 (press/release) と 1006 (SGR 拡張座標) は共通で、違いは移動報告のモード (1003 = any-event / 1002 = button-event) だけである。1003 は待機中の移動でも 1 セルごとに報告を送るため、next_event を 1 反復 1 イベントで捌くループでは、ドラッグ以外の移動が大量に積もって描画が遅れて見えることがある。ドラッグ中の移動しか要らない用途では mouse_drag_on を使うと移動イベントが激減し、応答が軽くなる。hover を追う必要がある UI だけ mouse_on を選ぶ。

import { next_event, Event, kinds, buttons, actions } := "std:term/event"
{ Key, Mouse, Eof } := Event
ev := next_event()!
match ev {
  Eof => ()
  Key(k) =>
    match k.kind {
      kinds.up => scroll_up()
      _ => ()
    }
  Mouse(m) =>
    when (m.button == buttons.left && m.action == actions.press) { click(m.col, m.row) }
}

1.4 不正なバイト列

端末は UTF-8 とは限らないバイトを届ける (バイナリの貼り付け・別の符号化で動いている端末・回線の化け)。デコードは失敗しないEvent は失敗の tag を持たないので、読めないバイトも必ずイベントになる。

  • UTF-8 として読めないバイト列は char イベントの U+FFFD (REPLACEMENT CHARACTER) になる。次のいずれもこれにあたる:
    • 読めない先頭バイト (0xF50xFF)、符号点が U+10FFFF を超える綴り、サロゲート域 (U+D800〜U+DFFF) の綴り
    • 最短形でない綴り — 同じ符号点をより長い綴りでも書ける形 (C0 AF/ の 2 バイト形)。読める文字として通すと、綴りを見て弾く側をすり抜ける道ができる
    • 継続バイトが足りない綴り (E6 97 のように途中で終わるもの)。足りない分を 0 と見なして文字を作らない
    • 継続バイト (0x800xBF) を先頭に置いた綴り
  • 1 つの読めない綴りは 1 つのイベントになる。後続のバイトは次のイベントとして読む — 読めないバイトが入力の流れを壊さない

読めないバイトで止まると、貼り付け 1 回で TUI が落ちる。表示が化けることは利用者に見えるが、落ちたときに何が来たかは見えない。

2. next_event

  • 引数: なし
  • 戻り値: Future! で resolve 値を待つ (prelude.md §9.4 のブロッキング I/O 規則)
  • resolve 値は §1Event(裸のレコード。Result では包まれない)
    • 入力終端・I/O 失敗時も Eof というイベントが届く(失敗を表す Err にはならない)。ループは Eof の枝で終了する
  • std:termread_key/peek_key を内部で用いる。エスケープ列は read_key の先読みバッファー (term.md §2) と peek_key により、固定タイマー無しで揃えて解釈する
import { raw_mode } := "std:term"
import { next_event, Event } := "std:term/event"
{ Key, Mouse, Eof } := Event

raw_mode {
  mutable running := true
  while { running } {
    ev := next_event()!
    match ev {
      Eof => running = false
      Key(k) =>
        match k.kind {
          "char" => print("\r" + k.char)
          "up" => print("\rup")
          "ctrl" => when (k.char == "c") { running = false }  # Ctrl-C
          _ => ()
        }
      Mouse(_) => ()
    }
  }
}

3. 修飾キーの検出範囲

修飾フラグはバイト列から判別できた分だけ立つ。端末・プロトコルにより検出できる組合せは異なる。

フラグ 検出できる主な経路
ctrl C0 制御 (Ctrl+AZ = 1〜26)、修飾付き CSI (1;5)、kitty keyboard protocol (CSI code;mod u)、SGR マウス Cb の ctrl ビット
shift 修飾付き CSI (1;2)、kitty、SGR マウス Cb の shift ビット
alt ESC 前置 (Meta。ESC の直後に通常キーが続く形)、修飾付き CSI (1;3)、kitty、SGR マウス Cb の meta ビット

検出できない修飾は false になる (例: 多くの端末で Ctrl+Enter は素の Enter と区別できない)。アプリはこの「取れた分だけ」前提で書く。

4. decode — 純デコーダー (テスト・上級用途)

decode(next_byte, peek_byte) は 2 つのバイト読取り関数を注入して 1 イベントを組み立てる純関数で、実端末に依存しない。next_event() は実質 fork { decode(term.read_key, term.peek_key) } である。

  • next_byte: NextByte = { timeout_ms: Int | Future(Result(Int)) }term.read_key と同形 (1 バイト読む)
  • peek_byte: PeekByte = {| Result(Int) }term.peek_key と同形 (先読みバッファー先頭を消費せず覗く。空なら Err)
  • 戻り値: §1Event (Future ではなく組み立て済みの値。next_byte! 待ちは decode 内部で行う)

2 つの関数型は Event と同じく型メンバーとして export する。同じ形がデコード関数群に繰り返し現れるため名前を与えたもので、type は透過的なので基底の関数型と構造的に同一である (既存の注釈はそのまま通る)。import { decode, NextByte, PeekByte } := "std:term/event" で取り出して疑似リーダーの型注釈に使える。

バイトバーストを供給する疑似リーダーを与えれば、矢印・マウス・修飾・UTF-8・kitty・EOF の各系統を TTY 無しで検証できる。

# "q" の 1 バイトを与えて char イベントを得る (擬似リーダーの構築は省略)
ev := decode(fake_next [[113]], fake_peek)
match ev {
  Key(k) => k.kind  # "char"、k.char は "q"
  _ => ""
}

5. ヘッドレス(テスト)モード

next_eventstd:term の I/O に乗るため、ヘッドレスモード (term.md §6) がそのまま効く。標準入力にスクリプト化したバイト列 (エスケープ列を含む) を流し、HIKARI_TERM_HEADLESS=1 で TUI ループ全体を TTY 無しで駆動できる。decode は I/O を注入形で受けるので環境変数に依らずテストできる。