콘텐츠로 건너뛰기
Home » ASCIIMATICS로 UI를 구성하는 방법

ASCIIMATICS로 UI를 구성하는 방법

지난 글에서 asciimatics를 사용하여 간단한 애니메이션을 만드는 예를 보면서, 애니메이션을 구성하는 요소들에 대해 알아보았습니다. 그리고 이 애니메이션을 구성하는 요소들이 UI에서는 어떤 식으로 대응하는지를 간략하게 언급했습니다. 이를 다시 정리해보면 다음과 같은 원리들이 보입니다.

  • 애니메이션처럼 UI를 갖춘 앱도 여러 개의 화면을 가질 수 있습니다. 메모앱이나 연락처와 같은 앱을 생각하면 목록화면과 내용 편집 화면의 두 개의 화면이 필요할 것입니다.
  • 이러한 화면들은 각 화면이 하나의 장면(Scene)에 대응합니다. 각 화면은 적어도 1개 이상의 창을 포함할 것입니다. 각각의 창은 프레임(Frame)이라는 클래스를 서브클래싱하여 구현합니다. 이 Frame은 다시 일종의 Effect 입니다. (그래서 장면에 추가될 수 있습니다.)

즉 애니메이션과 UI를 구성하는 것 모두, 동일한 체계에서 사용하는 빌딩블럭만 약간 달라진다는 것을 알 수 있습니다. UI를 구성하는데 사용되는 기본적인 빌딩블럭은 프레임과 위젯입니다. 프레임은 하나의 뷰를 구성하는 단위에 대응하며, 위젯은 뷰 속에 포함되는 여러 UI 요소 하나하나에 대응합니다. 애니메이션에서의 이펙트는 라이브러리가 제공하는 기본적인 것들 사용했는데, 프레임은 Frame 클래스를 서브클래싱하여 자신이 원하는 화면을 구성해야 합니다. 이 부분이 TUI를 구성해보려는 우리가 가장 관심을 갖는 부분일 것입니다. 그러면 asciimatics에서는 어떻게 UI를 구성하는지 살펴보겠습니다.

프레임과 레이아웃

Frame 은 일반적으로 ‘창’으로 인식하는 사각형 영역을 만드는 클래스이고, Effect의 한 종류입니다. 매 시간마다 계속해서 갱신된다기 보다는, 사용자의 입력에 따라 다음 프레임으로 진행하는 애니메이션이라고 생각해도 좋습니다. 프레임 안에는 버튼이나 체크박스, 텍스트 필드와 같은 UI 요소들이 들어갈 수 있습니다. asciimatcs에서는 다른 GUI 프레임워크와 비슷하게 이러한 요소들을 위젯(Widget)이라고 부릅니다. (사실 Frame 자체도 위젯의 일종입니다.) 화면을 구성하기 위해서는 프레임 내에 이런 위젯들을 추가해야 하는데, 위젯들을 가로 세로로 적절한 크기를 가지고 배치되도록 하기 위해서 “레이아웃(Layout)”이라는 객체를 사용합니다.

레이아웃은 위젯들을 한 단위로 그룹지어서 가로 혹은 세로로 배열해주는 역할을 하며, 실제로 눈에 보이는 요소는 아닙니다. 하나의 프레임에는 1개 이상의 레이아웃이 들어갈 수 있으며, 레이아웃은 추가되는 순서대로 프레임 내부 영역에서 위에서 아래로 쌓이게 됩니다. 그리고 다시 이 레이아웃에 위젯을 추가하여 위젯을 프레임이 붙이게 됩니다.

레이아웃 객체는 Layout 타입으로, 생성하는 방식에 따라서 가로 혹은 세로로 내부가 나뉘게 됩니다. 전체폭을 1개 칼럼으로 사용하는 레이아웃의 경우 텍스트박스나 리스트박스와 같이 큰 위젯을 사용하며, 위젯은 추가하는 순서대로 위에서 아래로 배치됩니다. 가로로 요소들을 배치해야 하는 부분에 대해서는 내부를 여러 개의 칼럼으로 나눈 레이아웃을 구성할 수도 있습니다. 이 경우에는 위젯을 추가할 때 어느 칼럼에 들어가게 될 것인지를 지정해 줄 수 있고, 이를 통해 버튼이나 텍스트 필드를 좌우로 배치할 수 있습니다. (같은 칼럼에 2개 이상의 위젯이 들어가면 그 내부에서 다시 위에서 아래 방향으로 쌓입니다.)

레이아웃 자체도 위젯의 일종이기 때문에 레이아웃 속에 다시 레이아웃을 넣어서 좀 더 복잡한 UI를 만들 수 있을 것도 같지만, 실험은 해보진 않았습니다.

위젯

버튼, 텍스트 필드 같은 개별 UI 요소들을 위젯이라고 합니다. asciimatics에는 기본적인 웹 폼(form)에서 사용되는 요소들이 이미 정의되어 있습니다. 대략 다음과 같은 위젯들이 있어서 활용할 수 있습니다. 각각의 사용법은 공식 문서를 확인해보는 것이 좋겠습니다.

  • Button
  • Label / Text / TextBox
  • CheckBox / RadioButtons
  • DropdownList
  • ListBox / MultiColumnListBox
  • DatePicker / TimePicker
  • Divider / VerticalDivider
  • FileBrowser
  • PopupDialog / PopupMenu
  • Scrollbar

기본적인 위젯들은 준비가 되어 있는 상황이니, 이를 잘 활용하면 되고 필요하다면 기존 위젯을 서브클래싱하여 새로운 위젯을 만들 수도 있습니다. 그정도로 복잡한 게 필요하다면 GUI쪽을 생각해보는게 더 좋을 것 같다는 생각도 듭니다만…

간단한 예를 살펴보겠습니다. 제목과 본문으로 나뉘어 있는 작은 글을 하나 작성하는 UI 입니다. 제목과 본문은 각각 Text,TextBox 를 사용합니다. Text 는 웹의 <input type="text"/> 요소에 대응하며, TextBox<textarea></textarea> 에 대응한다고 보면 됩니다.

  • 상단에는 제목, 본문을 입력하는 필드가 있음
  • 하단에는 저장, 취소에 대한 2개의 버튼이 가로로 배치됨
  • 프레임은 가로 50%, 세로 80%의 비율로 화면을 채우고, 가운데에 배치됨

이상의 내용을 적용하여 편집기 화면을 만드는 예제를 아래와 같이 작성할 수 있습니다.

from asciimatics.widgets import Frame, Layout, Text, TextBox, Button


class Editor(Frame):
    def __init__(self, screen: Screen):
        # Frame의 초기화 메소드를 사용하여 크기 및 몇 가지 설정을 지정
        super().__init__(
                screen,
                screen.height * 4 // 5,
                screen.width // 2,
                reduce_cpu=True)

        # 전체폭 레이아웃 생성
        layout = Layout([100,], fill_frame=True) 
        self.add_layout(layout)
        # 텍스트 필드 및 텍스트 박스 추가
        layout.add_widget(Text("Title:", "title"))
        layout.add_widget(TextBox(5, "Body:", "body", as_string=True))

        # 버튼 표시를 위한 레이아웃 생성
        buttons = Layout([1, 1, 2])
        self.add_layout(buttons)
        # 버튼 추가
        buttons.add_widget(Button("Save", on_click=self.on_save), 0)
        buttons.add_widget(Button("Cancel", on_click=self.on_cancel), 1)
   
        # 레이아웃 추가 완료. 크기 계산
        self.fix()

먼저 각각의 창은 Frame을 서브클래싱하여 만듭니다. FrameEffect의 한 종류이므로, 생성할 때 자신이 표시될 화면 객체를 전달 받습니다. 그리고 초기화 메소드를 오버라이딩하여, 여기서 레이아웃을 구성합니다. 레이아웃을 만드는 방법은 다음과 같습니다.

  1. Layout 객체를 생성합니다.
  2. frame.add_layout() 메소드를 사용해서 프레임에 Layout을 추가합니다.
  3. 다시 layout.add_widget() 메소드를 사용해서 레이어 내에 위젯을 추가합니다. 여기서 중요한 것은, 위젯을 추가하기 전에 레이아웃 객체는 반드시 프레임에 추가되어 있어야 합니다.
  4. 필요한 만큼 1~3을 반복하여 화면을 구성하고
  5. self.fix() 를 호출하여 크기 계산을 합니다.

레이아웃에 얼마나 큰 위젯이 몇 개나 들어가는지에 따라서 각 레이아웃이 차지하는 공간의 크기가 결정되고, 프레임이 스크롤 될 것인가 하는 등의 여러 표현을 위한 값이 결정되기 때문에 최종적으로는 self.fix()를 호출하여 주어야 합니다. 레이아웃을 생성할 때 fill_frame=True 옵션을 주면 해당 레이아웃은 프레임의 크기보다 레이아웃의 총 높이가 작을 때, 자동으로 커지면서 화면을 채우게 됩니다.

위젯은 버튼처럼 보통 한 줄에서 표현 되는 것들이 있고, 텍스트 박스나 리스트 박스 같은 어떤 위젯들은 여러 줄을 차지합니다. (즉, 높이값을 갖습니다.) 보통 위젯을 초기화할 때 첫번째 인자는 레이블, 두 번째 인자는 해당 요소의 이름인데, 높이가 있는 위젯들은 높이 값을 먼저 받습니다. 화면 크기에 따라 동적으로 높이가 조절되어야 하는 위젯의 경우에는 높이값에 Widget.FILL_FRAME 을 그 값으로 전달해주면 self.fix() 가 처리되는 시점에 높이가 결정될 수 있습니다. (Widgetasciimatics.widgets.Widget 입니다.)

버튼에는 on_click= 인자에 클릭되었을 때, 호출될 함수를 지정할 수 있습니다. 클릭 이벤트 핸들러 콜백으로는 이벤트에 관한 어떤 정보가 전달되지 않습니다. 아래와 같이 코드를 좀 더 추가해서 실제로 실행했을 때 UI를 화면에 표시할 수 있는 형태로 개선할 수 있습니다.

from asciimatics.widgets import Frame, Layout, Text, TextBox, Button
from asciimatics.screen import Screen
from asciimatics.scene import Scene
from asciimatics.exceptions import StopApplication


class Editor(Frame):
    def __init__(self, screen: Screen):
        super().__init__(
                screen,
                screen.height * 4 // 5,
                screen.width // 2,
                reduce_cpu=True)

        layout = Layout([100,])
        self.add_layout(layout)
        layout.add_widget(Text("Title:", "title"))
        layout.add_widget(TextBox(5, "Body:", "body", as_string=True))

        buttons = Layout([1, 1, 2])
        self.add_layout(buttons)
        buttons.add_widget(Button("Save", on_click=self.on_save), 0)
        buttons.add_widget(Button("Cancel", on_click=self.on_cancel), 1)

        self.fix()


    def on_save(self):
        pass

    def on_cancel(self):
        raise StopApplication('User clicked cancel')


def main():
    def demo(screen: Screen):
        editor = Editor(screen)
        screen.play([
            Scene([editor], name="Editor View"),
            ])

    Screen.wrapper(demo)

if __name__ == '__main__':
    main()

위 코드를 실행해보면 화면이 표시되고, 탭키나 화살표키로 포커스를 이동하면서 문자를 입력하고 버튼을 선택해 볼 수 있습니다. (마우스 클릭도 가능합니다.)

StopApplication 예외를 일으키면 앱이 종료됩니다. 우선 취소 버튼에는 이 동작을 하게끔 연결해두었으니, 실제로 화면에서 Cancel 버튼을 클릭하면 앱이 종료됩니다.

화면 전환하기

새 글을 작성하고 “완료”버튼을 클릭했다면 글을 저장한 후 목록 화면 같은 것으로 화면이 전환되어야 할 것입니다. 목록에서도 다시 글을 선택했다면 해당 글을 편집할 수 있도록 편집기 화면으로 전환되어야 하겠죠. 화면을 전환하는 것은 애니메이션에서 장면을 전환하는 것과 같다고 했습니다. 애니메이션을 재생하는 screen.play() 메소드는 각 장면들의 리스트를 인자로 받고, 리스트에 들어있는 순서대로 장면들이 재생됩니다. 하나의 장면이 재생을 완료하면, 다음 장면이 재생됩니다. 이렇게 자동으로 순차적으로 이동하는 것 외에, 특정한 이벤트에 의해서 다른 장면으로 이동하는 방법이 있습니다.

asciimatics.exceptions 패키지에는 몇 가지 예외가 정의되어 있습니다. 앞선 예제에서는 StopApplication 예외를 사용해서 앱을 종료했습니다. NextScene 예외는 현재 장면을 종료하고 다른 장면으로 이동할 때 사용됩니다. 예외를 만들 때, 장면의 name 에 해당하는 값을 전달하면 그 장면으로 전환합니다. 물론 현재 장면의 이름을 넣으면 현재 장면을 다시 시작하는 효과를 낼 수 있습니다.

아래 예제에서 화면 전환의 구현 방법을 확인할 수 있습니다. ViewA 라는 프레임을 만들고 여기에 “Editor”라는 버튼을 넣었습니다. 버튼을 클릭하면 다음 장면으로 넘어갑니다. 위에서 작성한 Editor 클래스를 사용해서 편집 화면을 생성할 수 있겠죠. screen.play() 에서 각 Scene을 생성할 때 name= 파라미터를 사용해서 장면의 이름을 정의해주면 됩니다. 여기서 쓰이는 이름이 NextScene 을 생성할 때 전달하는 이름이 됩니다.

from asciimatics.scene import Scene
from asciimatics.screen import NextScene, Screen, StopApplication
from asciimatics.widgets import Frame, Layout, Label, Button

from editor_001 import Editor

class ViewA(Frame):
    def __init__(self, screen: Screen):
        super().__init__(screen, screen.height, screen.width)
        layout = Layout([2, 1, 1], fill_frame=True)
        self.add_layout(layout)
        layout.add_widget(Label("A"), 0)
        layout.add_widget(Button("Editor", on_click=self.on_edit), 1)
        layout.add_widget(Button("Quit", on_click=self.on_quit), 2)
        self.fix()

    def on_edit(self):
        raise NextScene('Edit View')

    def on_quit(self):
        raise StopApplication('User terminated the app.')


def main():
    def demo(screen: Screen):
        screen.play([
            Scene([ViewA(screen),], name="A"),
            Scene([Editor(screen, None),], name="Edit View")
            ])

    Screen.wrapper(demo)

if __name__ == '__main__':
    main()

Editor 클래스에서도 self.on_save() 메소드에서 장면을 전환하도록 코드를 넣어주면 다시 “A”화면으로도 돌아올 수 있을 것입니다.

지금까지 asciimatics를 사용해서 UI를 구성하는 방법에 대해서 살펴보았습니다. 각각의 뷰는 Frame 의 서브클래스를 작성해서 만들 수 있으며, 이 때 레이아웃과 위젯을 각각 추가해서 화면을 구성합니다. 구성된 화면이 실제 애플리케이션의 부분으로서 작동하려면 이 UI에 데이터를 연결하여, 각 위젯에서 데이터의 상태를 나타내고 또 저장이나 삭제와 같은 액션이 처리될 수 있도록 해야 할 것입니다. 다음 글에서는 asciimatics TUI 앱에서 데이터와 UI를 어떤 식으로 연결하는지에 대해서 알아보도록 하겠습니다.