Python Textual 라이브러리로 TUI App 개발:프로젝트 활용

지진우·2023년 12월 12일
0

1. Introduction

이전 포스팅에선 간단한 TUI app을 만들었습니다. 하지만 이는 너무 간단해보입니다. 그렇기에 조금 심화과정으로 저의 Jobdam 에서 적용되었던 몇 가지 기능을 추가해 TUI app을 꾸며보도록 하겠습니다.

2. Screen

Textual에서 Screen은 터미널의 크기를 차지하는 위젯의 컨테이너입니다. 특정 앱에는 여러 개의 화면이 있을 수 있지만 한 번에 하나의 화면만 활성화됩니다.

먼저 앱을 실행했을 때 맨 처음 보이는 화면을 만들어보겠습니다.

from textual.screen 에서 Screen 위젯 컨테이너를 가져와 특정 화면을 만들 수 있습니다.

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Welcome

class HomeScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Welcome()

화면을 터미널 상에 출력하고 싶을 때는 push_screen이라는 메소드를 이용해 화면을 출력할 수 있습니다.

...
class ChatApp(App):
    
    def on_mount(self) -> None:
        self.app.push_screen(HomeScreen())


if __name__  == "__main__":
    app=ChatApp()
    app.run()

on_mount 메소드를 이용해 앱이 실행될 때 가장 먼저 마운트되는 것을 push_screen으로 설정한 뒤 앱을 실행하면 다음과 같은 결과를 얻을 수 있습니다.

2-1. HomeScreen

Textual은 빠른 스타일링을 위해 여러 컨테이너 위젯을 지원합니다.

  • Horizontal: 가로 레이아웃을 위한 컨테이너
  • Vertical: 세로 레이아웃을 위한 컨테이너
  • Grid: 그리드 레이아웃을 위한 컨테이너
  • Container: 수직 레이아웃을 위한 컨테이너

위는 대표적인 textual의 컨테이너 위젯이며 Textaul container공식문서에서 더 많은 컨테이너를 확인할 수 있습니다.

저는 홈화면에서 container 위젯으로 수직 레이아웃을 만들고 컨테이너 내에 위젯의 css 스타일을 적용하기 위해 classes와 css 파일인 main.css 만들어주었습니다.

Buttonclasses 또한 위젯의 스타일을 지정하는 것이고 id는 나중에 상호작용을 위해 쓰일 예정입니다.

그리고 이 앱에 css 파일을 적용하기 위해선 CSS_PATH에 경로를 제공하면 됩니다.

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.containers import Container
from textual.widgets import Button

class HomeScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Container(
            Button("Get Started", id="get_started", classes="button_widget"),
            Button("Exit", id="exit", classes="button_widget"),
            classes="home_screen_container",
        )

class ChatApp(App):
    CSS_PATH = "main.css"
    
    def on_mount(self) -> None:
        self.app.push_screen(HomeScreen())

위젯의 css 스타일을 적용한 방식이 더 궁금하다면 Textual css style, Textual text css를 참고해주세요

HomeScreen {
    background: black;
}

HomeScreen .home_screen_container {
    align: center middle;
}

HomeScreen .button_widget {
    width: 10%;
    margin: 1 1 1 1;
    border: blank white;
    background: black;
}

위와 같이 설정이 끝났다면 아래와 같은 결과를 얻을 수 있습니다.

하지만 아직 버튼을 눌러도 아무런 반응을 보이지 않습니다. 이것은 후에 설정을 해주겠습니다.

2-2. ChatScreen

이제 이전에서 만들었던 간단한 채팅화면을 적용할 차례입니다.

홈화면을 만들었던 것과 비슷하게 Vertical 컨테이너를 이용해 수직 레이아웃을 만들어 그 안에 위젯들을 배치시킵니다.

class ChatScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Vertical(
            RichLog(classes="richlog_widget"),
            Input(placeholder="Enter chat", classes="input_widget"),
            classes="vertical_layout"
        )

아래는 ChatScreen의 css 구성입니다.

ChatScreen {
    background: black;
}

ChatScreen .vertical_layout {
    align: center middle;
    background: black;
}

ChatScreen .input_widget {
    width: 100%;
    margin-top: 1;
    margin-bottom: 1;
    border: white;
}

ChatScreen .richlog_widget {
    align: center middle;
    background: black;
    scrollbar-size-vertical: 1;
    scrollbar-color: white;
    border: white;
}

화면의 구성은 모두 끝이 났습니다. 하지만 아직 버튼이나 인풋의 상호작용이 일어나지 않습니다. 이제 이벤트 핸들러를 통해 상호작용을 할 수 있도록 해보겠습니다.

3. 상호작용

on 데코레이터를 이용해 이벤트 핸들러로 동작하게 합니다.

만약 event.button.idget_started라면 ChatScreen을 화면에 출력하고 event.button.idexit이라면 앱을 종료합니다.

from textual import on

class HomeScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Container(
            Button("Get Started", id="get_started", classes="button_widget"),
            Button("Exit", id="exit", classes="button_widget"),
            classes="home_screen_container",
        )
        
    @on(Button.Pressed)
    def button_pressed_handler(self, event: Button.Pressed) -> None:
        if event.button.id == "get_started":
            self.app.push_screen(ChatScreen())
        elif event.button.id == "exit":
            self.app.exit()

ChatScreen도 상호작용을 위해 코드를 추가해줍니다.

class ChatScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Vertical(
            RichLog(classes="richlog_widget"),
            Input(placeholder="Enter chat", classes="input_widget"),
            classes="vertical_layout"
        )
                
    @on(Input.Submitted)
    def input_submitted_handler(self, event: Input.Submitted):
        log = self.query_one(RichLog)
        log.write(f"earthquake: {event.value}")
        input = self.query_one(Input)
        input.value = ""    

전체 코드는 다음과 같습니다.

from textual import on
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.containers import Container, Vertical
from textual.widgets import Input, RichLog, Button


class HomeScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Container(
            Button("Get Started", id="get_started", classes="button_widget"),
            Button("Exit", id="exit", classes="button_widget"),
            classes="home_screen_container",
        )
        
    @on(Button.Pressed)
    def button_pressed_handler(self, event: Button.Pressed) -> None:
        if event.button.id == "get_started":
            self.app.push_screen(ChatScreen())
        elif event.button.id == "exit":
            self.app.exit()
            
            
class ChatScreen(Screen):
    
    def compose(self) -> ComposeResult:
        yield Vertical(
            RichLog(classes="richlog_widget"),
            Input(placeholder="Enter chat", classes="input_widget"),
            classes="vertical_layout"
        )
                
    @on(Input.Submitted)
    def input_submitted_handler(self, event: Input.Submitted):
        log = self.query_one(RichLog)
        log.write(f"earthquake: {event.value}")
        input = self.query_one(Input)
        input.value = ""    
    


class ChatApp(App):
    CSS_PATH = "main.css"
    
    def on_mount(self) -> None:
        self.app.push_screen(HomeScreen())


if __name__  == "__main__":
    app=ChatApp()
    app.run()

이제 앱을 실행해 Get_started를 클릭하여 ChatScreen으로 이동하면 다음과 같은 화면으로 간단한 채팅앱이 구현된 것을 확인할 수 있습니다.

다음 포스팅에선 구축한 앱을 PyPI에 배포하는 법을 살펴보겠습니다!

profile
높이보다는 멀리, 넓게보다는 깊게

0개의 댓글