Home » ASCIIMATICS 101 – 2

ASCIIMATICS 101 – 2

지난 글에서 ASCIIMATICS로 기본적인 애니메이션 효과를 구현하는 방법에 대해 살펴보았다. 이번에는 텍스트 기반 UI를 구성하는 방법에 대해 다뤄보려고 한다. 텍스트 기반 UI는 여느 GUI와 마찬가지로 기본적인 UI 컴포넌트인 위젯을 조합하는 형태로 만들어진다. 대신에 일반적인 애니메이션 재생과 달리 이러한 위젯들은 사용자와의 상호작용을 전제로 디자인되어 있으며, 화면 뒤에 존재하는 데이터를 표시하고 변경하는 동작을 수반한다.

텍스트 입력 필드나 버튼등과 같은 기본적인 UI 요소들은 Widget의 서브 클래스들로 정의되어 있고, asciimatics.widgets 서브 모듈내에 존재한다. 위젯을 조합하여 표시하는 화면은 프레임(asciimatics.widgets.Frame)이다. 위젯들을 프레임 내에 배치하는 작업은 레이아웃(Layout) 객체가 그 작업을 대신해준다. 구성이 완료된 UI 내에서 이 클래스들간의 계층 구조는 다음과 같이 표현할 수 있다.

Frame +-- Layout +-- Widget > Text
      |          +-- Widget > TextBox
      +-- Layout +-- Widget > Button
                 |-- Widget > Button
                 +-- Widget > Button

프레임

프레임은 하나의 화면 단위(View)로 생각할 수 있으며, 실제로는 Effect를 기반으로 작성되어 있다. 어떤 화면을 만들 때에는 Frame을 베이스로 서브 클래스를 작성한다. 이렇게 작성한 프레임의 인스턴스는 다른 Effect와 동일하게 Scene에 포함시켜서 Screen으로 재생하면 해당 UI가 화면에 표시된다.

레이아웃

레이아웃은 프레임 내에서 위젯을 가로방향으로 배치하는 역할을 담당한다. 프레임의 초기화 메소드에서 레이아웃 객체를 생성하고, 프레임의 add_layout() 메소드를 사용해서 레이아웃을 프레임에 연결한다. 레이아웃은 여러 컬럼을 가지고 있을 수 있으며, 이렇게 연결한 레이아웃에 add_widget(wdg, column)메소드를 사용해서 특정한 칼럼에 위젯을 삽입할 수 있다.

간단한 편집 화면만들기

위 그림처럼 생긴 화면을 만들어보려고 한다. 간단한 텍스트 편집 화면이다. 제목과 본문을 입력하는 두 개의 입력 필드가 있고, 아래에 버튼이 하나 있다. (녹색 부분이 입력하는 부분인데, 여기에 해당 화면의 구조를 그려보았다.) 그림 속 작성된 표를 참고하면 프레임과 레이아웃, 위젯들의 관계를 이해하는데 도움이 될 것 같다.

이 화면은 하나의 프레임 내에 두 개의 레이아웃이 아래 위로 배치되어 있다. 첫 번째 레이아웃에는 텍스트 필드와 텍스트 박스가 아래위로 배치되고, 그 아래 두 번째 레이아웃 내에는 버튼이 가운데 하나 표시된다. 이 정보를 기초로 프레임 클래스인 View를 한 번 작성해보도록 하자.

  • 첫 번째 레이아웃은 텍스트 박스 때문에 가능한 영역을 가득채우게 되며, 텍스트 박스 역시 가능한 영역을 가득 채우게 된다.
  • 버튼을 가운데 적절한 크기로 배치하기 위해 아래쪽 레이아웃은 가로로 5등분하고 가운데 칸에만 버튼을 배치했다.

뷰 생성 및 초기화 방법

뷰의 생성자 메소드에서는 다음과 같은 일을 하게 된다.

  1. super().__init__()을 호출하여 프레임의 기본적인 속성을 초기화한다.
  2. 뷰에 추가될 레이아웃을 생성한다.
  3. self.add_layout() 메소드에 생성한 레이아웃을 넘겨 레이아웃을 연결한다.
  4. 각 레이아웃 아래에 필요한 위젯 인스턴스들을 추가로 생성하여 연결해서 UI 구조를 구성한다.
  5. self.fix()를 호출하여 각 레이아웃, 위젯의 가로/세로 크기 계산을 완료한다.

우리가 만들어보고자 하는 뷰는 View라는 이름의 클래스로 작성되나. 이 뷰는 화면 폭과 너비의 80%를 차지하는 크기로 만들 것이다. 프레임 자체의 초기화에는 부모의 초기화 메소드를 사용한다. 기본적으로 필요한 인자들은 다음과 같다.

  • 뷰가 표시될 스크린
  • 높이
  • 너비
  • 프레임 상단에 표시할 제목
  • 테두리 표시 여부
  • CPU 사용을 적게하기

Frame이 Effect의 일종인데, Effect는 기본적으로 초당 20프레임으로 작동한다. 하지만 UI의 경우에는 사용자 입력을 제외하면 스스로 UI를 업데이트할 이유가 없기 때문에 불필요한 CPU 사용량을 줄이기 위해 reduce_cpu=True 옵션을 준다. 별도의 다른 이펙트를 동시에 돌릴 게 아니라면 항상 쓴다고 봐야 한다.

from asciimatics.widgets import (
    Widget, 
    Text, 
    TextBox, 
    Button, 
    Layout, 
    Frame
)
from asciimatics.exceptions import StopApplication, NextScene


class View(Frame):
    def __init__(self, screen: Screen):
        super().__init__(
            scree, 
            int(screen.height * 0.8), 
            int(screen.width * 0.8),
            title="Demo", has_border=True, 
            reduce_cpu=True
        )
        

레이아웃 추가 및 위젯 추가

뷰는 크게 두 구역으로 나뉘는데, 입력 필드 부분이 있는 body와 하단에 버튼이 있는 bottom으로 나눈다. bottom 부분에는 여러 개의 버튼이 표시될 수 있을 것이고, 이는 해당 영역이 가로로 여러 칼럼으로 구분되어야 함을 암시한다. 또 TextBox는 여러 줄에 걸친 영역으로 표시될 것이므로 body 레이아웃의 높이는 화면의 높이 방향을 채우는 식으로 들어가야 한다.

레이아웃을 생성할 때에는 칼럼의 구성을 정수 리스트로 지정하고, 높이값을 줄 수 있다. body는 가변 높이를 가지므로 fill_frame=True 옵션을 준다. bottom의 경우 버튼 1개를 중앙에 배치하고 싶으므로 [1, 1, 1, 1, 1]로 가로를 5등분하였다.

class View(Frame):
    def __init__(self, screen: Screen):
        super().__init__(...)
        # 레이아웃 생성 및 추가
        ly_body = Layout([100], fill_frame=True)
        ly_bottom = Layout([1,1,1,1,1])
        self.add_layout(ly_body)
        self.add_layout(ly_bottom)
        
        # 첫번째 레이아웃에 위젯 추가
        ly_body.add_widget(Text(label="Title", name="title"))
        ly_body.add_widget(TextBox(
            Widget.FILL_FRAME, lable="Contents", name="body")
        )
        # 두 번째 레이아웃에 위젯 추가
        ly_bottom.add_widget(Button("Close", self._on_close), 2)
        self.fix()

입력 위젯들은 생성할 때 표시될 레이블과 이름 속성을 준다. TextBox는 생성시 높이도 지정해줘야 하는데, Widget.FILL_FRAME 값을 지정하면 프레임을 최대한 채우는 높이가 자동으로 계산되어 지정된다. 각 위젯의 생성에는 다음과 같은 파라미터 값을 사용했다. 각각의 위젯의 세부적인 생성 파라미터는 공식 문서를 참고하자.

  • Text(label=, name=) (문서)
  • TextBox(height, label=, name=) (문서)
  • Button(label, on_click) (문서)

모든 위젯의 추가가 완료되면 self.fix()를 호출하여 레이아웃을 계산한다. 참고로 위젯을 레이아웃에 추가하는 코드앞에 반드시 레이아웃을 프레임에 추가하는 코드가 와야 한다.

위젯의 값

실행 시점에 사용자에 의해 위젯에 입력된 값은 해당 위젯의 value 속성으로 접근할 수 있다. 하지만 모든 프레임이 모든 위젯에 대한 참조를 가지게끔 작성하는 것은 무척 번거로운 일이다. 이를 조금 편하게 하는 방법이 있는데, 모든 Frame 들은 내부에 data 라는 프로퍼티를 가지고 있는데, Frame.save()를 호출하면 내부의 모든 위젯의 값이 이 data라는 사전에 모이게 된다. 이 때 각 위젯의 name 속성은 이 data 사전에서 키가 되어 해당 이름의 위젯의 value 속성을 얻는데 사용하게 된다.

버튼 이벤트 핸들러

버튼을 생성하면서 클릭됐을 때 (마우스를 지원하는 터미널의 경우에는 GUI처럼 클릭으로 사용할 수 있다.)나 포커스가 버튼에 있는 상태에서 엔터키가 눌러지면 생성할 때 지정한 함수가 호출된다. 여기서는 입력된 내용을 출력하고, 앱을 종료해보자. 앱을 종료하는 것은 StopApplication 예외를 일으키면 된다.

class View(Frame):
    # ...
    def _on_close(self):
        self.save()
        print(f"Title: {self.data['title']}\nContent:\n{self.data['body']}")
        raise StopApplication()

이렇게 생성한 뷰는 Effect의 일종 이므로 이전 예제와 동일한 방식으로 화면에 띄울 수 있다. 단, 재생 시간을 -1로 준다는 게 다를 뿐이다.

from asciimatics.screen import Screen, ManagedScreen
from asciimatics.scene import Scene
def demo(screen: Screen):
    scene = Scene([View(screen)], -1, name="Main")
    screen.play([scene])
Screen.wrapper(demo)

이렇게 해서 예제를 실행해보면 터미널 상에서 작동하는 간단한 편집 폼이 돌아가는 것을 볼 수 있다. 탭 키나 방향키를 눌러서 각 필드와 버튼의 포커스를 이동할 수 있고, 종료하면 입력했던 내용이 출력되는 것을 알 수 있다.

화면 전환

필요에 따라서는 화면이 2개 이상 사용되는 애플리케이션을 만들 때도 있을 것이다. 전환에 필요한 각각의 화면은 하나의 Scene이라고 생각하면 된다. TUI 앱에서 뷰는 곧 프레임이며, 각 장면에 하나의 뷰가 있다고 생각하는 것이다. 장면의 전환은 NextScene 예외를 사용한다. 특정한 이벤트 핸들러에서 NextScene(sceneName)을 일으킨다. 그러면 sceneName에 해당하는 name 속성을 가지고 있는 장면으로 화면이 전환된다.

그런데 화면이 전환된다는 것은 조회하거나 편집하는 정보의 층위가 바뀐다는 것이고 따라서 전환 전후의 화면에서 표시하는 정보는 어떤 관계가 있어야 한다. 그런데 ASCIIMATICS에서는 NextScene예외를 통한 화면 전환은 데이터 전달에 대한 매커니즘을 포함하고 있지 않다. 그냥 현재 장면을 폐기하고 다음 장면으로 가는 것이다. 그러면 장면 간 전환에서 데이터 전달은 어떻게 해야할까? 다음 글에서 뷰 사이에 데이터를 주고 받을 수 있는 방법에 대해서 살펴보자.

0 Comments

No Comment.