Textual은 터미널 환경에서 복잡한 UI를 가진 TUI앱을 빠르게 개발할 수 있는 애플리케이션 개발 프레임워크이다. 다른 의존성은 거의 필요하지 않으며, 모든 플랫폼의 터미널에서 작동한다. TUI앱의 가장 큰 장점은 SSH와 같은 원격 터미널에서도 UI를 갖춘 앱을 실행할 수 있다는 것이다. 파이썬에서 GUI를 구현하기 위한 여러 라이브러리나 프레임워크를 몇 년간 찾아 보았지만, 이 만큼 사용하기 편리하고 멋진 도구는 만난적이 없었다. Textual 홈페이지의 가이드 중 일부를 가져와서 간단한 강좌의 형식으로 소개해보고자 한다.
앱 구성하기
Textual 앱을 구성하고 실행하는 방법은 다음과 같다.
textual.app.App
클래스를 상속하여 커스텀 앱 클래스를 정의한다.- 앱 클래스에
compose()
를 구현하여 화면에 포함될 위젯을 추가한다. on_mount()
를 통해 앱 화면이 그려질 때 실행할 초기화를 실행한다.- 그 외 액션이나 이벤트 처리 핸들러를 작성한다.
- 스크립트 최상위 레벨에서 앱의 인스턴스를 만들고
run()
을 실행한다. (e.g.MyApp().run()
)
Textual은 간단한 텍스트 레이블, 인풋 박스, 버튼, 스크롤 가능한 컨테이너, 팝업, 알림 영역 등의 UI 요소들을 모두 정의하고 제공하고 있다. 여기에는 데이터 테이블이나 트리 뷰, 리스트 박스와 같은 좀 더 복잡한 기능을 가진 요소들까지 준비되어 있어서, 왠만한 GUI앱과 대등한 수준의 UI를 갖춘 앱을 손쉽게 만들 수 있다. 실제로 사용이 굉장히 편리하기 때문에 GUI 프레임워크를 사용하는 것보다 훨씬 낫다. 게다가 Textual Web이라는 확장 의존을 설치하면 TUI앱을 웹브라우저 상에서 실행할 수 있기 때문에 실질적으로 유용한 도구를 만들기에도 부족함이 없다.
Compose
텍스트레이블, 텍스트 박스, 버튼 등과 같이 눈에 보이는 모든 UI 요소를 위젯이라고 한다. (사실 위젯에는 화면, 레이아웃 컨테이너 등도 모두 포함된다.) 앱의 UI는 앱에 화면을 추가하고, 각 화면에 이 위젯들을 조합하여 구성하는 것이 권장되는 방법이다. (물론 커스텀 위젯을 직접 작성하고, 위젯이 렌더링되는 코드를 직접 작성하는 것도 가능하다)
Textual에서 사실 가장 눈에 띄는 부분은 화면에 위젯을 추가하는 것이다. 화면에 레이아웃 컨테이너를 추가하고, 다시 레이아웃 컨테이너에 위젯을 추가하는 다른 라이브러리들과는 달리 모든 웹과 위젯은 compose()
라는 메소드를 가지고 있으며, 이 메소드를 구현하면 다른 위젯을 조합하여 화면이나 위젯의 UI를 구성할 수 있다. 독특한 점은 compose()
가 제너레이터의 형식으로 구성되며, 추가할 위젯을 순차적으로 yield 하는 것이 전부이다. 또 컨테이너들은 with
구문처럼 사용하고, 블럭 내부에서 포함될 위젯들을 yield 하면 된다. 따라서 레이아웃을 구성하는 코드가 선언적인 코드 형식이 되며 아주 깔끔해진다.
아래는 레이블, 텍스트편집영역과 버튼이 세로로 배치되는 화면의 예를 보여주는데, 이 때 버튼들은 가로로 묶어서 배치하려고 한다. 코드만 봐도 뭐가 어디에 붙게 되는지가 선명하다.
def compose(self) -> ComposeResult:
yield Static("This is a sample App.")
yield TextArea()
with Horizontal():
yield Button(id="Cancel")
yield Button(id="Ok")
만약 이전에 Tk나 다른 GUI/TUI 라이브러리를 사용해보지 않았다면, 이런 스타일이 얼마나 매력적인지 쉽게 체감하기 힘들 것 같아서, 비교를 위해 asciimatics에서 TUI를 구성하는 코드를 간단히 예를 들어 보겠다.
class EditorView(Frame):
def __init__(self, screen, model):
super().__init__(screen, screen.height, screen.width)
layout = Layout([100], fill_frame=True)
self.add_layout(layout)
layout.add_widget(Text("This is a sample App."))
layout.add_widget(TextBox(10))
layout2 = Layout([1, 1, 1, 1])
self.add_layout(layout2)
layout2.add_widget(Button("Cancel", self._cancel), 1)
layout2.add_widget(Button("OK", self._ok), 2)
self.fix()
두 코드를 비교해보면 코드의 양도 양이지만, 일일이 만들어서 어디에 붙여야 한다는 코드보다는 Textual의 선언적인 레이아웃 코드가 훨씬 더 간결하며, 코드만 봐도 UI의 구성을 바로 떠올릴 수 있을만큼 직관적이다.
이벤트
UI를 가지는 앱을 만드는 라이브러리들은 거의 필수적으로 이벤트를 처리할 수 있는 시스템을 가지고 있어야 하며, Textual도 예외는 아니므로 이벤트 시스템을 가지고 있다. 앱이나 위젯 클래스에는 on_
으로 시작하고 이벤트 이름이 오는 메소드를 작성하면 같은 이름의 이벤트를 해당 클래스가 처리할 수 있게 된다.
이벤트 핸들러는 def
를 사용해서 동기식 메소드로 작성할 수도 있고, async def
를 써서 비동기 핸들러로 만들 수도 있다. 단, on_mount()
의 경우에는 비동기 메소드로 작성하는 경우, query()
류 메소드를 사용할 수 없게 되기 때문에 동기식으로 사용해야 한다.
from textual.app import App
from textual.events import Key
class Sample(App):
COLORS = "white maroon red purple fuchsia olive yellow navy teal aqua".split()
# 디폴트 스크린이 설치된 직후에 배경색을 지정한다.
def on_mount(self) -> None:
self.screen.styles.background = "darkblue"
# 숫자키가 입력되면 대응하는 색상으로 배경색을 변경한다.
async def on_key(self, event: Key):
if event.key.isdecimal():
self.screen.styles.background = self.COLORS[int(event.key)]
if __name__ == '__main__':
app = Sample()
app.run()
액션
앱이나 위젯에는 action_
으로 시작하는 메소드를 작성할 수 있다. 액션은 메소드를 직접 호출하지 않고 문자열의 형태로 간접적으로 호출하거나, 링크에 연결하여 실행할 수 있다. 앱의 키 바인딩에 연결 지을 수 있다.
액션은 run_action()
이라는 비동기 코루틴을 통해 실행하며, 호출 시에는 액션의 이름과 전달할 인자를 함수 호출 문법과 동일하게 작성한 텍스트로 전달한다.
class Sample(App):
BINDINGS = [("b", "set_background('darkblue')", "Blue")]
def action_set_background(self, color: str) -> None:
self.screen.styles.background = color
async def on_key(self, ev: Key) -> None:
if ev.key == 'r':
await self.run_action("set_background('red')")
마운트
compose()
를 사용하면 기존에 작성한 위젯을 재사용할 수 있고, 코드도 간편하므로 이 방식이 앱의 UI를 구성하는데 있어서 가장 추천되는 방식이다. 하지만 어떤 경우에는 특정한 이벤트의 결과로 새로운 위젯이 UI에 추가되어야 할 수도 있다. 이 때 mount()
메소드를 사용한다.
다음 예제는 앱 실행 후 아무 키나 누르면 Textual이 자체적으로 제공하는 데모 위젯을 동적으로 마운트하여 표시한다. 위젯에 포함된 버튼을 클릭하면 앱이 종료된다.
from textual.app import App
from textual.widgets import Welcome
class WelcomeApp(App):
def on_key(self) -> None:
self.mount(Welcome())
def on_button_pressed(self) -> None:
self.exit()
WelcomeApp().run()
동적으로 위젯을 마운팅할 때 문제는 mount()
를 호출한 즉시 새로 추가한 위젯에 접근할 수 없는 경우가 대부분이라는 것이다. Textual은 다음 번 메시지 핸들러까지는 마운팅 작업을 완료하지만, 이 시점이 mount()
가 리턴되는 시점은 아니다. 따라서 새로 마운트한 위젯에 액세스하려면 비동기로 마운팅한 후, 해당 작업이 완료되기를 기다려야 한다.
class WelcomeApp(App):
async def on_key(self) -> None:
await self.mount(Welcome())
self.query_one(Button).label = "YES!"
CSS
Textual은 UI 위젯의 외형을 코들 관리하지 않고 CSS를 사용하여 정의할 수 있다. 따라서 파이썬 코드와 디자인을 완전히 분리할 수 있다. 앱이나 위젯의 CSS
클래스 속성을 사용하여, CSS를 파일 내부에 정의할 수도 있고, 앱의 CSS_PATH 속성을 이용하여 외부 CSS 파일을 사용할 수도 있다. CSS 파일의 문법은 대체로 웹에서 사용하는 CSS와 거의 동일하다. 단 px, pt와 같은 단위는 사용하지 않으며, 대신 fr 이라는 단어를 사용할 수 있다. 정확히는 모르겠지만 프레임 내에서 차지하는 상대적인 크기가 아닐까 싶다.)
앱에 CSS를 적용하는 방식은 별도의 css 파일을 만들어서 앱과 같은 폴더에 배치하는 방식과 CSS 스타일시트 문자열을 앱의 CSS 속성으로 하드코딩하는 방법이다.
- CSS 문법은 SASS 문법과 유사한 문법이 허용된다.
- 외부 파일로 만드는 경우,
textual-dev
도구를 사용하면, 앱 실행 중에 CSS를 외부에서 수정하여 실시간으로 반영해 볼 수 있다.
간단한 예제를 통해서 살펴보자. 간단한 텍스트 문서 작성 폼의 레이아웃을 아래와 같이 구성한다.
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widgets import Button, TextArea, Static
class MyApp(App):
CSS_PATH = "styles.scss"
def compose(self) -> ComposeResult:
with Container(id="form"):
yield TextArea(id="contents")
with Container(id="buttons"):
yield Button(id="cancel")
yield Button(id="ok")
MyApp().run()
스타일 시트는 다음과 같이 작성한다.
Screen {
Container#form {
layout: vertical;
align: center middle;
TextArea {
color: #eee;
height: 2fr;
}
#buttons {
Button {
margin: 1 3;
}
#cancel {
background: $secondary;
}
#ok {
color: yellow;
}
}
}
}
그리고 textual-dev 패키지를 설치한 후 스크립트를 실행해보자. 스타일시트를 편집하던 편집기를 닫지 말고 다른 터미널 창에서 열도록 한다. textual-dev 패키지가 설치되면 실행 가능한 textual이라는 명령이 설치된다.
> pip install textual-dev
> textual run styles01.py --dev
화면이 실행되면 아래와 같이 버튼이 한쪽에 쏠린 것으로 보일 것이다. 스타일 시트에서 지정을 빼먹은 부분이 있다.
편집기에 열어둔 스타일를 다음과 같이 수정하고 저장한다. 물론 ‘<– 추가’라고 되어 있는 부분은 올바른 스타일시트 문법이 아니니 그대로 넣으면 안된다.
Screen {
Container#form {
layout: vertical;
align: center middle;
TextArea {
color: #eee;
height: 2fr;
}
#buttons {
layout: horizontal; <---- 추가
align-horizontal: center; <---- 추가
Button {
margin: 1 3;
}
#cancel {
background: $secondary;
}
#ok {
color: yellow;
}
}
}
}
스타일 시트를 저장하면, 그 순간 앱의 UI가 바뀌면서 아래와 같이 버튼이 제위치로 다시 정렬되는 것을 확인할 수 있다.
이 정도 수준의 편의성이면 Qt나 다른 여느 GUI 프레임워크보다 사용하기 편리한 것 같다. 실제로 간단한 앱을 만들 때에는 정말 빠르게 작업할 수 있다. 그 외에도 같은 개발사에서 만든 Rich를 사용하면 간단한 명령줄 도구를 작성할 때 UI를 훨씬 더 예쁘게 만들 수 있는데, Textual은 내부적으로 Rich와 많은 부분은 공유하고 있는 것으로 보인다.