⌨️

CLIエディタのUIの仕組みに触れてみる【ANSI escape sequence】

に公開

やったこと

夏休みを利用してNeovimに入門しています。

とりあえずLazyVim[1]から初めたのですが、今までLinuxサーバーでちょろっとviを操作した程度だった身としては、CLIのイメージと異なるリッチなUIに驚きました。


https://www.lazyvim.org/

色々設定すればVSCodeのようなGUIエディタと大差ない表示がなされます。マウスレスで高速なテキスト操作とこのUIを併用できるのなら、確かに慣れればかなり快適そうです。

しかし、触っているうちにふとUIについて気になってきました。

数年前から変わってなければVSCodeはelectron製だと思われます。Web技術であのリッチなUIを実現できるのは分かるんですが、NeovimのようなCLIエディタでどうやってサジェストのポップアップのような表示を実現しているのかがイマイチ分かりません。

そこで、簡単に調査してみました。

ANSI escape sequence

私はGUIネイティブな人間なので、恥ずかしながらCLIの表示について詳しくありませんでした。どうやらこのANSI escape sequenceがコアの技術のようです。

https://en.wikipedia.org/wiki/ANSI_escape_code

ESC[<parameters>command

の記法で書き、CLI上でテキストのスタイリングやカーソル操作の命令を行えます。最初は\n\r等のASCII制御文字みたいなもんかとも思いましたが、実際はもっと高レイヤの技術であり、自分はコンソール画面に対する表示制御の命令と理解しました。

実際にやってみます。

その他様々な制御については以下のサイトが参考になりました。

https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797

ポップアップを作ってみる

こんな感じの表示をANSI escape sequenceで試しに作ってみます。

作ってみたやつです。かなりの簡易版ですが、原理的にはこんなもんではないでしょうか。

https://youtu.be/MwwKB8ngekw

以下コードです。

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用のライブラリを使うと思われます。

脚注
  1. https://www.lazyvim.org/ ↩︎

Discussion