CLIエディタのUIの仕組みに触れてみる【ANSI escape sequence】
やったこと
夏休みを利用してNeovimに入門しています。
とりあえずLazyVim[1]から初めたのですが、今までLinuxサーバーでちょろっとviを操作した程度だった身としては、CLIのイメージと異なるリッチなUIに驚きました。
色々設定すればVSCodeのようなGUIエディタと大差ない表示がなされます。マウスレスで高速なテキスト操作とこのUIを併用できるのなら、確かに慣れればかなり快適そうです。
しかし、触っているうちにふとUIについて気になってきました。
数年前から変わってなければVSCodeはelectron製だと思われます。Web技術であのリッチなUIを実現できるのは分かるんですが、NeovimのようなCLIエディタでどうやってサジェストのポップアップのような表示を実現しているのかがイマイチ分かりません。
そこで、簡単に調査してみました。
ANSI escape sequence
私はGUIネイティブな人間なので、恥ずかしながらCLIの表示について詳しくありませんでした。どうやらこのANSI escape sequenceがコアの技術のようです。
ESC[<parameters>command
の記法で書き、CLI上でテキストのスタイリングやカーソル操作の命令を行えます。最初は\n
、\r
等のASCII制御文字みたいなもんかとも思いましたが、実際はもっと高レイヤの技術であり、自分はコンソール画面に対する表示制御の命令と理解しました。
実際にやってみます。
その他様々な制御については以下のサイトが参考になりました。
ポップアップを作ってみる
こんな感じの表示をANSI escape sequenceで試しに作ってみます。
作ってみたやつです。かなりの簡易版ですが、原理的にはこんなもんではないでしょうか。
以下コードです。
import sys
import termios
import tty
class Popup:
"""Popup implementation"""
def __init__(self, start_row, start_col, options):
self.selected_index = 0
self.start_row = start_row
self.start_col = start_col
self.options = options
def _move_cursor(self, vx, vy):
"""
Move cursor to virtual position
Virtual origin is (1, 1)
"""
ver_move = vx - 1
hor_move = vy - 1
sys.stdout.write(
f"\033[{self.start_row + ver_move};{self.start_col + hor_move}H"
)
def _draw_box(self, width, height):
"""Draw box of popup"""
self._move_cursor(1, 1)
sys.stdout.write("┌" + "─" * (width - 2) + "┐")
for i in range(2, height):
self._move_cursor(i, 1)
sys.stdout.write("│" + " " * (width - 2) + "│")
self._move_cursor(height, 1)
sys.stdout.write("└" + "─" * (width - 2) + "┘")
def _get_key(self):
"""Get keypress"""
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
key = sys.stdin.read(1)
if key == "\033": # such as arrow keys
key += sys.stdin.read(2)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return key
def draw(self):
"""Draw popup"""
# calculate size
width = max(len(option) for option in self.options) + 3
height = len(self.options) + 2
while True:
self._draw_box(width, height)
for i, option in enumerate(self.options):
self._move_cursor(2 + i, 2)
if i == self.selected_index:
sys.stdout.write(f"\033[7m{option:<{width - 2}}\033[0m")
else:
sys.stdout.write(f"{option}")
sys.stdout.flush()
# Handle input
key = self._get_key()
if key == "\033[A": # Up arrow
self.selected_index = (self.selected_index - 1) % len(self.options)
elif key == "\033[B": # Down arrow
self.selected_index = (self.selected_index + 1) % len(self.options)
elif key == "q": # quit
break
def main():
"""ANSI escape sequence Demo"""
# save screen
sys.stdout.write("\033[?1049h")
# clear screen
sys.stdout.write("\033[2J")
sys.stdout.write("Press Enter\n")
# hide cursor
sys.stdout.write("\033[?25l")
sys.stdout.flush()
input()
popup = Popup(2, 1, ["Option 1", "Option 2", "Option 3", "Option 4"])
try:
popup.draw()
finally:
# show cursor
sys.stdout.write("\033[?25h]")
# restore screen
sys.stdout.write("\033[?1049l")
sys.stdout.flush()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)
今回はなるべく生のANSI escape sequenceで書きましたが、実際のエディタではCLI UI用のライブラリを使うと思われます。
Discussion