콘텐츠로 건너뛰기
Home » ASCIIMATICS TUI – 샘플 앱 예제

ASCIIMATICS TUI – 샘플 앱 예제

지금까지 우리는 몇 개의 글을 통해 asciimatics의 기본적인 사용법과 이 라이브러리를 사용하여 TUI를 구성하는 방법에 대해서 살펴보았습니다. 그리고 여기에 데이터 모델을 연결하기 위한 기초작업은 어떻게 준비하는지도 알아보았습니다. 오늘은 이러한 내용들을 바탕으로 간단한 앱을 만들어 보겠습니다.

TODO 앱

우선 간단한 할일의 목록과 완료여부를 표시할 수 있는 Todo 앱을 만들어봅시다. 기본적으로 등록된 작업의 목록과, 새 목록을 등록할 수 있는 UI를 하나의 화면에 추가합니다. 작업 목록에서 하이라이트된 항목을 선택(Enter 키 누름)하면 완료 여부가 반전되어 표시되도록 하고, 목록에 아래에는 텍스트 필드와 추가 버튼을 두어 새 작업을 등록하게 합니다. 작업을 삭제하는 기능은 없습니다.

데이터모델 컨트롤러

이전 글에서 작성했던 추상 클래스를 기반으로 TODO 작업의 데이터를 관리할 컨트롤러를 만듭니다. 할 일 항목은 제목과 완료여부 두 개의 간단한 속성으로만 구성하면 되므로, 하나의 할 일 항목을 하나의 사전으로 표현하고 전체 데이터는 사전의 리스트가 되도록 합니다.

여기서 조금 신경을 써야할 부분은 get_item_list() 메소드인데, 이 메소드의 결과값을 MultiColumnListBox 의 목록으로 바로 전달하려고 합니다. ListBoxoptions 항목은 (title, value) 의 리스트를 사용했는데, 멀티칼럼 리스트의 경우에는 타이틀에 각 칼럼에 표시할 내용의 리스트를 전달해주어야 합니다.

또 리스트에 정보를 추가하거나, 정보를 꺼내 오는 과정에서는 각 요소의 사본을 사용하도록 주의를 기울일 필요가 있습니다.

from model import BaseModel

class TodoController(BaseModel):
  def __init__(self):
    self.data = []
    self.cid = None

  @property
  def current_id(self):
    return self.cid

  @current_id.setter
  def current_id(self, val):
    self.cid = val

  def get_item_list(self):
    return [[["V" if d["done"] else " ", d["title"]], i] for i, d in enumerate(self.data)]

  def get_current_item(self):
    if self.current_id is None:
      return {"title": "", "done": False}
    # 리스트 내 요소의 사본을 리턴
    return dict(**self.data[self.current_id])

  def add_new_item(self, info):
    # 인자로 전달받은 사전의 사본을 추가
    self.data.append(dict(**info))
    self.current_id = len(self.data) - 1

  def update_new_item(self, info):
    if self.current_id is None:
      self.add_new_item(info)
    else:
      self.data[self.current_id] |= info

  def delete_item(self, item_key):
    self.data.pop(item_key)

그리고 뷰는 다음과 같이 만듭니다. 먼저 화면을 구성하는 코드입니다. MultiColumnListBox 를 초기화할 때에는 columns= 라는 파라미터가 있는데, 몇 개의 칼럼이 있고 각 칼럼의 폭과 정렬방식 같은 것을 약속된 문자로 표기하는 정도가 추가됩니다.

from asciimatics.screen import Screen
from asciimatics.widgets import Widget, Frame, Layout, MultiColumnListBox, Text, Button


class View(Frame):
  def __init__(self, screen: Screen, model: TodoController):
    super().__init__(screen, 
                     screen.height // 2,   # 화면 폭/높이의 절반
                     screen.width // 2,
                     on_load=self.reload,
                     reduce_cpu=True)
    self.model = model
    
    # 주요 위젯
    self.listbox = MultiColumnListBox(Widget.FILL_FRAME,
                        columns=["^3", "<9"],
                        options=self.model.get_item_list(),
                        on_select=self.on_select)
    self.txt_title = Text("", "title")

    # 레이아웃
    layout1 = Layout([100,], fill_frame=True)
    self.add_layout(layout1)
    layout2 = Layout([5, 1])
    self.add_layout(layout2)

    layout1.add_widget(self.listbox)
    layout2.add_widget(self.txt_title, 0)
    layout2.add_widget(Button("Add", on_click=self.on_add), 1)

    self.fix()

이상의 코드에서 이벤트 처리 관련 메소드를 몇 개 써놨습니다. 다음과 같은 것들이네요.

  • reload() : 화면을 새로 고침 (주로 리스트박스의 내용을 갱신)
  • on_select() : 리스트 박스에서 항목을 선택 (엔터) 했을 때. 선택한 항목의 완료여부를 토글해줄 겁니다.
  • on_add() : 추가 버튼을 클릭했을 때.

그러면 이번에는 각각의 이벤트 처리 동작을 작성합니다. 데이터의 처리는 모델 컨트롤러에서 모두 일임하므로 UI에 관한 처리만 하면 됩니다. 화면을 갱신할 때에는 입력 필드에 들어있던 값을 빈 문자열로 다시 초기화해주는 것이 그래도 조금 깔끔합니다.

class View(Frame):
  ...
  def reload(self):
    self.listbox.options = self.model.get_item_list()
    self.txt_title.value = ""

  def on_select(self):
    self.model.current_id = self.listbox.value
    info = self.model.get_current_item()
    info["done"] = not info["done"]
    self.model.update_current_item(info)
    self.reload()

  def on_add(self):
    info = {"title": self.txt_title.value.strip(), "done": False}
    if info["title"] != "":
      self.model.add_new_item(info)
      self.reload

그리고 한가지만 추가로! 원래 asciimatics의 애니메이션은 q 키를 누르면 종료됩니다. 그런데 위젯을 사용하면 이 단축키가 더이상 작동하지 않습니다. 우리가 만든 앱은 이번에는 종료키가 없습니다. 그러면 이 때 <Esc> 키를 눌러서 종료하고 싶을 때에는 어떻게 할까요? asciimatics 공식문서에는 Global key handling이라는 항목으로 간단히 소개하고 있는데, 키를 처리하는 함수를 작성해서 Screen.play() 메소드의 unhandled_input= 파라미터로 전달하라고 하고 있습니다.

뷰별로 처리하는 단축키 구현

왠지 이것보다는 프레임별로 단축키라든지 이런 것들을 처리하고 싶을 수가 있는데, 이를 위해서는 process_event() 메소드를 오버라이딩하도록 합니다. 이 메소드는 프레임 위에서 어떤 입력 이벤트가 발생했을 때 호출됩니다. 만약 내가 처리할 수 있는 이벤트라면 처리하면 되고, 그렇지 않다면 이벤트 그 자체를 리턴해주면 됩니다. (그러면 계층 구성 같은 적절한 계통을 따라 이벤트가 전파되겠죠?) 우리의 뷰에서도 이 메소드를 처리하여 <Esc> 키를 누를 때 종료되도록 해 봅시다. 참고로 Esc 키의 키 코드는 Screen.KEY_ESCAPE 상수로 정의되어 있습니다.

from asciimatics.event 

class View(Frame):
  ....
  def process_event(event: Event)
    if isinstance(event, KeyBoardEvent) and event.key_code == Screen.ESCAPE:
      raise StopApplication("esc")
    return event

이상의 내용으로 다음과 같이 실행할 준비를 합니다.

def demo(screen: Screen):
  model = TodoController()
  view = View(screen)
  screen.play([Scene([view,], name="Main"),])

if __name__ == "__main__":
  Screen.wrapper(demo)

메모앱

간단한 단일 화면으로된 앱을 만들어 보았으니, 이번에는 목록과 상세를 전환하는 방식의 메모 작성 앱을 만들어보겠습니다. 사실 asciimatics 공식 문서에 있는 것과 같은 앱입니다. 이번에는 데이터 모델보다 뷰를 먼저 작업해보겠습니다.

편집 뷰

먼저 편집 화면을 구성해보겠습니다. 편집화면에는 상단에 제목과 본문을 입력하는 두 개의 필드가 있고, 이 들은 각각 TextTextBox로 만듭니다. 참고로 TextBox를 생성할 때, as_string=True 옵션을 줍니다. 이 옵션이 있으면 위젯의 내용이 단일 문자열로 만들어지고, 없다면 각 줄의 리스트로 만들어지게 됩니다. 문자열을 TextBoxvalue 프로퍼티 값으로 넣어줄 때 에러가 난다면, 이 옵션이 명시되어 있는지 확인해봐야 합니다.

그리고 아래 쪽에는 버튼들을 넣습니다. 저장과 취소 두 개의 버튼이 있을 것입니다. 저장 버튼은 선택했을 때 현재 뷰의 데이터를 데이터 모델에 저장하고 리스트 뷰로 빠져나갑니다. 취소 버튼은 저장하는 동작 없이 리스트 뷰로 빠져나갑니다. 뷰를 빠져나가 다른 화면으로 전환하는 방법은 NextScene() 예외를 만들어서 던지는 것입니다. 이 때 뷰의 이름을 인자로 전달해주는데, 각각의 뷰는 "Edit View", "List View"로 정했습니다.

만약 목록에서 기존 메모를 선택해서 편집 상태로 이 뷰에 진입했다면, 화면이 그려지기 직전에 화면의 상태를 결정할 때 reset() 이 호출됩니다. 데이터 모델에서 읽어들인 값이 이 시점에 각 위젯에 들어가야 하기 때문에 이 메소드를 오버라이드해주어야 합니다. 오버라이드할 때에는 반드시 super().reset() 과 같이 부모 클래스의 메소드를 다시 호출해주어야 합니다.

편집 뷰를 표현하는 클래스의 코드는 아래와 같습니다.

from asciimatics.screen import Screen
from asciimatics.widgets import Widget, Frame, Layout, ListBox, TextBox, Text, Button
from asciimatics.event import Event, KeyboardEvent
from asciimatics.exceptions import NextScene, StopApplication

class EditView(Frame):
  def __init__(self, screen: screen, model: BaseModel):
    super().__init__(screen, screen.height * 4 // 5, screen.width * 2 // 3,
                     reduce_cpu=True)
    self.model = model
    layout1 = Layout([100,], fill_frame=True)
    layout2 = Layout([1,1,2])
    self.add_layout(layout1)
    self.add_layout(layout2)
    layout1.add_widget(Text("title:", "title"))
    layout1.add_widget(TextBox(Widget.FILL_FRAME, "body:", "body", as_string=True))
    layout2.add_widget(Button("Save", on_click=self.on_save), 0)
    layout2.add_widget(Button("Cancel", on_click=self.on_cancel), 3)

    self.fix()

  def reset(self):
    # 화면이 그려질 때, 모델 데이터와 UI를 동기화
    super().reset()
    self.data = self.model.get_current_item()

  def on_save(self):
    self.save()
    self.model.update_current_item(self.data)
    self.on_cancel()

  def on_cancel(self):
    raise NextScene("List View")

목록 뷰

다음은 목록 화면을 만드는 클래스입니다. 화면을 구성하는 초기화 부분은 다른 화면과 크게 다르지 않습니다. 목록에서 제목에서 엔터키를 누르면 해당 글을 편집하도록 이동하게 했고, 목록에서 포커스가 바뀔 때마다 편집, 삭제 버튼의 상태를 변경하게 했습니다.

class ListView(Frame):
  def __init__(self, screen: screen, model: BaseModel):
    super().__init__(screen, screen.height * 4 // 5, screen.width * 2 // 3,
                     on_load=self.reload, reduce_cpu=True)
    self.model = model
    self.list = ListBox(Widget.FILL_FRAME, options=self.model.get_item_list(),
                        on_select=self.on_edit,
                        on_change=self.on_change)
    self.btn_edit = Button("Edit", on_click=self.on_edit)
    self.btn_delete = Button("Delete", on_click=self.on_delete)

    layout1 = Layout([100,], fill_frame=True)
    layout2 = Layout([1,1,1,1])
    self.add_layout(layout1)
    self.add_layout(layout2)
    layout1.add_widget(self.listbox)
    layout2.add_widget(Button("Add", on_click=self.on_add), 0)
    layout2.add_widget(self.btn_edit, 1)
    layout2.add_widget(self.btn_delete, 2)
    layout2.add_widget(Button("Quit", on_click=self.quit), 3)

    self.fix()

  def reload(self):
    self.listbox.options = self.model.get_item_list()

  def on_change(self):
    self.model.current_id = self.listbox.value
    self.btn_edit.disabled = self.model.current_id is None
    self.btn_delete.disabled = self.model.current_id is None

  def on_edit(self):
    self.model.current_id = self.listbox.value
    raise NextScene("Edit View")

  def on_delete(self):
    if self.model.current_id is not None:
      self.model.delete_item(self.model.current_id)
      self.reload()

  def on_add(self):
    self.model.current_id = None
    raise NextScene("Edit View")

  def quit(self):
    raise StopApplication("User terminated the app.")

  def process_event(self, event: Event):
    if isinstance(event, KeyBoardEvent) and event.key_code == Screen.KEY_ESCAPE:
      raise StopApplication("User terminated the app.")
    else:
      return super().process_event(event)

데이터 모델

이번에는 실제로 작성한 내용을 저장할 수 있도록 데이터 모델을 작성합니다. sqlite3 모듈을 사용하여 데이터베이스 파일에 콘텐츠를 저장하고 관리하도록 합니다.

import sqlite3
from pathlib import Path

from model import BaseModel


class NoteController(BaseModel):
    def __init__(self, path: Path | str | None = None):
        if path is None:
            self.db = sqlite3.connect(":memory:")
        else:
            self.db = sqlite3.connect(path)

        self.db.row_factory = sqlite3.Row

        self.db.cursor().execute(
            """CREATE TABLE IF NOT EXISTS notes (
                    id INTEGER PRIMARY KEY,
                    title TEXT,
                    body TEXT,
                    create_dt DATETIME DEFAULT CURRENT_TIMESTAMP,
                    modified_dt DATETIME DEFAULT CURRENT_TIMESTAMP
                )"""
        )

        self._cid: int | str | None = None

    @property
    def current_id(self):
        return self._cid

    @current_id.setter
    def current_id(self, val):
        self._cid = val

    def get_item_list(self):
        return self.db.cursor().execute("SELECT title, id FROM notes").fetchall()

    def get_current_item(self):
        if self.current_id is None:
            return {"title": "", "body": ""}
        return (
            self.db.cursor()
            .execute(
                """SELECT * FROM notes WHERE id=?
                """,
                (self.current_id,),
            )
            .fetchone()
        )

    def add_new_item(self, info: dict[str, str | int | None]):
        self.db.cursor().execute(
            """INSERT INTO notes (title, body)
                VALUES(:title, :body)""",
            info,
        )
        self.db.commit()

    def update_current_item(self, info: dict[str, str | int | None]):
        if self.current_id is None:
            self.add_new_item(info)
        else:
            info |= {"id": self.current_id}
            self.db.cursor().execute(
                """UPDATE notes SET title=:title, body=:body
                    WHERE id=:id""",
                info,
            )
            self.db.commit()

    def delete_item(self, item_key):
        self.db.cursor().execute("DELETE FROM notes WHERE id=?", (item_key,))
        self.db.commit()

실행하기

두 개의 뷰의 인스턴스를 만들고 각각을 다른 장면에 할당하여 스크린에 재생하면 앱이 작동합니다.

from asciimatics.scene import Scene

def main():
  def demo(screen: Screen):
    model = NoteController('notes.db')
    listview = ListView(screen, model)
    editview = EditView(screen, model)
    screen.play([
      Scene([listview,], name="List View"),
      Scene([editview,], name="Edit View"),
    ])
  Screen.wrapper(demo)

if __name__ == "__main__":
  main()

생각보다 코드가 많아 보이지만, 이전 글에서 다뤄봤던 내용들을 재탕하는 것이기 때문에 그리 어렵지는 않았을 것입니다. 여기까지가 asciimatics를 사용하여 TUI 앱을 만드는 기본적인 내용의 전부라고 생각됩니다. 사실 데이터 처리에 시간이 오래 걸리는 경우에 UI가 얼지 않도록 백그라운드 작업을 구현하는 것이라든지, 키보드나 마우스가 아닌 소켓이나 큐 등에서 발생하는 이벤트에 UI가 반응하는 등의 동작은 어떻게 하는 것인지 조금 더 연구해봐야 하겠지만, 가장 단순한 형태의 UI를 이런식으로 구성할 수 있다는 것을 참고하는 정도로 알아두는 것도 나쁘지 않을 것 같습니다. 그럼 이만.