Wireframe

ASCIIMATICS TUI – 데이터 다루기

입력필드나 드롭다운 메뉴 같은 UI들은 실제로는 어떤 값을 화면에 표시하기 위해 사용됩니다. 지금까지 asciimatics를 사용하여 UI를 구성하는 방법을 살펴보았는데, 앱이 다루는 실제 데이터와 UI가 어떻게 연결되고, 어떤 식으로 사용해야 하는지에 대한 내용은 아직 이야기 하지 않았습니다. 이제서야 이 이야기를 할 순서가 된 것 같습니다. 오늘은 asciimatics로 TUI를 구현할 때, 데이터를 연동하는 방법에 대해서 살펴보겠습니다.

asciimatics의 애니메이션 기능을 설명하면서, 이 라이브러리는 애니메이션 구축 측면에서는 실제 애니메이션 영화를 제작할 때 사용하는 스토리보드 기법을 차용했다고 했습니다. 그리고 UI를 구성하는 측면에서는 모델과 뷰를 구분합니다. 이 때 뷰는 asciimatics의 여러 위젯들을 통해 구현하게 되고, 모델에 대해서는 어떤 제약도, 요구하는 사양도 없습니다. 즉 asciimatics의 UI 라이브러리는 완전히 사용자 데이터 모델과 독립적이므로, 사용자가 원하는 형태와 구조의 데이터 모델 객체를 정의하면 됩니다. 이 데이터를 프레임에서 반영하기 위해서는 프레임의 서브 클래스를 만들 때, 데이터 모델 객체에 대한 참조를 추가해주면 됩니다. 화면 상에서 키보드나 마우스에 의한 이벤트가 발생했을 때, 주로 프레임의 메소드가 이를 처리하므로 데이터 모델에 대한 참조를 사용하면 됩니다.

위젯의 데이터

모든 위젯은 공통적으로 value 라는 속성을 갖고 있습니다. 이 값은 해당 위젯의 상태 혹은 표시/입력되는 값을 반환하게 됩니다. 반대로 이 값을 설정해주면 프로그램상에서 UI 요소의 상태를 직접 제어할 수도 있습니다. 예를 들어 이전 글에서 우리는 편집기 화면을 만들면서, 텍스트 필드를 추가할 때 다음과 같이 했습니다.

class Editor(Frame):
  def __init__(self, screen):
    # ...생략...

    layout.add_widget(Text("title:", "title"))

텍스트 필드를 생성했지만 이에 대한 별다른 참조를 프레임이 갖게 하지 않았습니다. 대신에 이렇게 코드를 바꿀 수 있습니다.

class Editor(Frame):
  def __init__(self, screen):
    # ...

    self.txt_title = Text("title:", "title")
    self.txt_body = TextBox(5, "body:", as_string=True)
    layout.add_widget(self.txt_title)

    # ...

  def on_save(self):
    # title, body 값을 사용
    self.txt_body.value = self.txt_title.value

저장 버튼을 누른다면 body 항목에 입력한 내용이 title 항목의 내용으로 채워지게 됩니다. 마치, HTML 에서 각각의 폼필드 DOM의 .value 속성을 액세스하는 것과 동일합니다.

하지만 화면에 표시되는 입력 필드의 수가 많다거나 하는 경우에는 일일이 모든 필드에 대한 참조를 만들고 value 속성을 쓰는 것이 불편할 수 있습니다. Frame은 이처럼 자신에게 종속된 위젯이 많은 경우에, 이들의 데이터를 편하게 참조할 수 있는 방법을 제공해줍니다. 바로 data라는 속성이 그것입니다. 프레임의 data는 일종의 프록시로, 프레임 내의 모든 위젯의 값을 사전 형식으로 사용할 수 있게 해줍니다. 바로 위 예제 코드에서 Text, TextBox 등의 위젯을 생성할 때, 레이블 다음에 넣은 필드가 각 필드의 이름인데, self.data에서 해당 위젯의 이름을 키로 사용하면, 해당 위젯의 값을 액세스하게 됩니다. 이 동작에는 읽기와 쓰기가 모두 지원됩니다.

단 주의해야 할 부분이 있는, self.data 를 통해서 값을 읽어오려 할 때에는 완전히 동기화되지 않은 상태일 수 있기 때문에 반드시 self.save()를 호출하여 각 필드의 상태값을 동기화 시킨 후에 사용해야 합니다.

데이터와 장면 전환

장면을 전환할 때, 다음 장면 혹은 프레임에게 전달할 수 있는 콜백이 존재하지 않기 때문에 장면과 장면 사이에 데이터를 주고 받을 수 있는 공식적인 방법은 존재하지 않습니다. 다만 asciimatics 공식 문서에서는 단일 데이터 모델 객체를 생성하고, 이 객체를 데이터가 필요한 모든 프레임의 속성으로 바인딩하여 데이터 모델의 상태를 공유하는 방법을 사용할 것을 권합니다.

팝업 표시하기

화면의 전환이 아닌 팝업의 표시는 어떻게 구현할까요? Frame 은 자신이 표시되는 화면 및 자신이 등록된 장면에 대한 참조를 각각 frame.screen, frame,scene 으로 가지고 있습니다. 그리고 장면에는 필요한 이펙트를 동적으로 추가하거나 제거할 수가 있습니다. 그렇다면 팝업 자체가 Frame이나 그와 비슷한 Effect의 서브 클래스 중 하나의 타입이라면 장면의 add_effect() 메소드를 사용하면 팝업을 표시할 수 있게 된다는 의미입니다.

PopupDialog 는 메시지와 함께 몇 개의 버튼을 표시할 수 있는 팝업 대화 상자를 생성합니다. 팝업 상자를 표시할 때에는 표시할 메시지와 버튼의 목록을 전달하면 됩니다. 이 때 표시할 버튼은 Button 인스턴스가 아니라, 버튼의 레이블로서 표시될 문자열들입니다. 팝업 대화상자의 on_close= 파라미터는 버튼을 클릭했을 때 호출될 콜백인데, 이 콜백 함수는 1개의 인자를 받습니다. 인자로 전달되는 값은 0부터 시작하는 선택된 버튼의 인덱스입니다.

팝업은 위젯의 일종이지만, 레이아웃에 포함되지 않습니다. 위젯은 그 자체로 또 Effect 이기 때문에, 현재 장면에 언제든지 추가될 수 있습니다. 프레임 내에서 우리는 self.scene 으로 현재 장면에 언제든 접근할 수 있기 때문에, self.scene.add_effect() 메소드를 사용해서 팝업을 표시할 수 있습니다.

class Editor(Frame):
  ...
  def on_delete(self):
    self.scene.add_effect(PopupDialog("Are you sure to delete it?", ["YES", "NO"],
                                      on_close=self.on_delete_confirm)
    )

  def on_delete_confirm(self, btn):
    if btn == 0:
      self.model.delete_current_item()

위 예제는 삭제 버튼을 클릭한 상황에서 다시 한 번 확인해주는 대화상자를 동적으로 생성해서 호출하는 방법을 나타내고 있습니다. 팝업은 아무 버튼을 선택하면 종료되면서, 종료 콜백을 호출하는데, 여기서 선택된 버튼을 구분하여 정해진 액션을 할 수 있습니다.

데이터 모델 컨트롤러

데이터 모델을 단순한 자료형으로서 사용하는 방법도 있지만, 보다 견고한 결과를 만들려면 데이터 모델 자체가 자료를 관리할 수 있는 컨트롤러의 역할을 겸할 수 있도록 하는 것도 좋겠습니다. 메모앱이나 연락처 앱 같은 것을 생각한다면 낱개의 레코드는 결국 키-값의 쌍이므로 사전으로 대체할 수 있고, 이러한 데이터가 여러 개 된다면 그러한 사전들의 리스트로 구성될 수 있습니다. 하지만 이러한 데이터를 파일이나 데이터베이스에 저장하고, 조회하는 등의 동작이 추상화 되어 있다면 구체적인 데이터 모델을 구현하지 않거나, 데이터 모델의 구현을 변경하더라도 UI는 그에 영향을 받지 않고 독립적으로 구현될 수 있는 것입니다. 여러 개의 데이터 목록을 관리하고, 개별 데이터의 각 필드를 조정할 수 있는 종류의 앱이라면 데이터 모델 컨트롤러는 대략 다음과 같은 속성을 가지고 있어야 합니다.

그래서 먼저 이런 동작을 포함하는 추상 클래스를 만들어 두고, UI를 구현하는 시점에는 이렇게 일반적인 추상 클래스를 사용하여 구현을 해 두면, 나중에 데이터모델의 구현과 상관없이 작동하는 앱을 만들 수 있습니다.

from abc import ABC, abstractmethod

class BaseModel(ABC):
    @property
    @abstractmethod
    def current_id(self):
        pass

    @current_id.setter
    @abstractmethod
    def current_id(self, val):
        pass

    @abstractmethod
    def get_item_list(self):
        pass

    @abstractmethod
    def get_current_item(self):
        pass

    @abstractmethod
    def add_new_item(self, info):
        pass

    @abstractmethod
    def update_current_item(self, info):
        pass

    @abstractmethod
    def delete_item(self, item_key):
        pass

이러한 모델 컨트롤러와 결합시킨 편집기는 다음과 같이 구현하면 됩니다. 어떤 앱을 만들 것인가에 따라서 model 의 구체적인 타입은 다를 수 있겠지만, 추상메소드를 통해 미리 정의한 인터페이스를 사용하는 것으로도 충분히 작성할 수 있습니다. 데이터 모델을 사용하는 시점은 주로 버튼을 클릭하는 시점이거나 표시할 데이터를 전달해주는 시점이되며, 그 코드 또한 짧고 간결하기 때문에, 이전 예제에서 UI만 구성했던 코드와 크게 달라지지 않는 것을 확인할 수 있습니다.

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

from model import BaseModel


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

        self.model = model

        layout = Layout([100,], fill_frame=True)
        buttons = Layout([1, 1, 2])
        self.add_layout(layout)
        self.add_layout(buttons)

        layout.add_widget(Text("Title:", "title"))
        layout.add_widget(TextBox(Widget.FILL_FRAME, "Body:", "body", as_string=True))
        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):
        self.save()
        if self.model.current_id is None:
            self.model.add_new_item(self.data)
        else:
            self.model.update_current_item(self.data)
        raise NextScene("List View")

    def on_cancel(self):
        self.scene: Scene
        self.scene.add_effect(
                PopUpDialog(self.screen,
                            "Do you want to discard the change?",
                            ["Yes", "No"],
                            on_close=self.on_confirm_cancel
                            )
                )

    def on_confirm_cancel(self, btn):
        if btn == 0:
            raise NextScene("List View")

Exit mobile version