Go

Go로 CUI 툴을 쉽고 편하게! gocui 사용기

드리프트2 2024. 9. 19. 21:56

Go로 CUI 툴을 쉽고 편하게! gocui 사용기

안녕하세요.

 

요즘 Go로 CUI·CLI 툴을 만드는 것에 푹 빠져 있는데요.

 

CUI 툴을 만들 때 사용하고 있는 라이브러리로 gocui라는 것이 있습니다.

어떤 건가요?

터미널 상에서 HTML의 폼(form)처럼 입력 인터페이스를 간단하게 만들 수 있습니다.

 

버튼이나 체크박스 등도 준비해두었는데요.

사용 방법

[_demos Github 예제](https://github.com/skanehira/gocui-component/tree/master/_demos에 있는 select 샘플을 바탕으로 설명해보겠습니다.

func main() {
    gui, err := gocui.NewGui(gocui.Output256)
    if err != nil {
        panic(err)
    }
    defer gui.Close()

    if err := gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
        panic(err)
    }

    component.NewSelect(gui, "Programming Language:", 0, 0, 21, 10).
        AddOptions("Go", "Java", "PHP", "Python", "Ruby", "C", "C++", "C#").
        Draw()

    if err := gui.MainLoop(); err != nil && err != gocui.ErrQuit {
        panic(err)
    }
}
  • gocui.NewGui(gocui.Output256)로 gocui 인스턴스를 생성해둡니다.
  • component.NewSelect로 select 컴포넌트 인스턴스를 생성합니다.
  • AddOptions로 select 목록에 선택하고 싶은 옵션들을 추가합니다.
  • Draw로 gocui 인스턴스에 설정을 추가합니다.
  • gui.MainLoop()를 호출하여, gocui 인스턴스에 추가된 설정을 바탕으로 화면 그리기나 키 바인딩 처리를 실행합니다.

기본 흐름은 위와 같이 gocui 인스턴스를 생성하고, 그것을 컴포넌트에 전달한 후에 메서드로 각 설정을 해주고 Draw로 전달된 gocui 인스턴스에 설정을 추가하는 방식인데요.

 

gocui 자체는 gocui 인스턴스에 뷰(view)의 설정을 추가한 후에 MainLoop로 한꺼번에 처리하는 동작을 하고 있습니다.

 

내부 처리

간단한 사용 방법을 설명했으니, 위의 Select 컴포넌트 내부에서 어떤 처리를 하고 있는지 살펴볼까요?

 

Select의 구조체는 다음과 같습니다.

type Select struct {
    *InputField
    options      []string    // 선택할 옵션 리스트를 보유
    currentOpt   int         // 현재 선택된 옵션의 인덱스
    isExpanded   bool        // 옵션 리스트가 열려 있는지 여부를 판단하는 플래그
    ctype        ComponentType // 컴포넌트 타입, Form에서 컴포넌트 판정에 사용
    listColor    *Attributes // 옵션 리스트의 색상 정의
    listHandlers Handlers    // 옵션 리스트를 열었을 때의 동작 정의
}

 

Select 자체는 InputField 컴포넌트를 내장하고 있으며, 그것을 확장한 컴포넌트입니다.

 

아래는 select 컴포넌트를 생성할 때의 처리입니다.

// NewSelect new select
func NewSelect(gui *gocui.Gui, label string, x, y, labelWidth, fieldWidth int) *Select {

    s := &Select{
        InputField:   NewInputField(gui, label, x, y, labelWidth, fieldWidth), // InputField 인스턴스 생성
        listHandlers: make(Handlers),
        ctype:        TypeSelect,
    }

    // Enter로 옵션 리스트를 열 수 있도록 InputField의 AddHandler를 사용하여 정의
    s.AddHandler(gocui.KeyEnter, s.expandOpt)

    // 옵션 리스트의 색상을 정의
    s.AddAttribute(gocui.ColorBlack, gocui.ColorWhite, gocui.ColorBlack, gocui.ColorGreen).
        // AddListHandler를 이용하여 옵션 리스트를 열었을 때, j/k 또는 ↑/↓로 이동, Enter로 선택할 수 있도록 정의
        AddListHandler('j', s.nextOpt).
        AddListHandler('k', s.preOpt).
        AddListHandler(gocui.KeyArrowDown, s.nextOpt).
        AddListHandler(gocui.KeyArrowUp, s.preOpt).
        AddListHandler(gocui.KeyEnter, s.selectOpt).
        // `InputField`를 내장하고 있으므로, 입력하지 못하도록 설정해야 함
        SetEditable(false)

    return s
}

 

Select에서 어려웠던 것은 옵션 리스트를 어떻게 표시하고 선택할 수 있게 할 것인가 하는 점인데요.

 

gocui의 사양상, 뷰(view)를 생성하여 뷰의 영역 내에서 문자를 그려야 합니다.

 

즉, 옵션 리스트를 표시할 때는 옵션 수만큼 뷰를 정의해야 합니다.

 

또한, 어떤 옵션을 선택하고 있는지 알 수 있도록 포커스 처리나 선택 후 닫는 처리도 필요합니다.

 

옵션 리스트를 표시하는 처리는 expandOpt에서 하고 있으니, 그 부분을 살펴보겠습니다.

func (s *Select) expandOpt(g *gocui.Gui, vi *gocui.View) error {
    if s.hasOpts() {
        s.isExpanded = true
        g.Cursor = false

        x := s.field.X
        w := s.field.W

        y := s.field.Y
        h := y + 2

        for _, opt := range s.options {
            // 옵션 리스트는 아래로 펼쳐지므로 y와 h 좌표를 증가시킵니다.
            y++
            h++

            // 옵션마다 뷰를 정의합니다.
            if v, err := g.SetView(opt, x, y, w, h); err != nil {
                if err != gocui.ErrUnknownView {
                    panic(err)
                }

                v.Frame = false
                v.SelFgColor = s.listColor.textColor
                v.SelBgColor = s.listColor.textBgColor
                v.FgColor = s.listColor.hilightColor
                v.BgColor = s.listColor.hilightBgColor

                // 설정한 키 바인딩을 옵션마다 추가합니다.
                for key, handler := range s.listHandlers {
                    if err := g.SetKeybinding(v.Name(), key, gocui.ModNone, handler); err != nil {
                        panic(err)
                    }
                }

                fmt.Fprint(v, opt)
            }

        }

        // 리스트를 열었을 때 선택된 옵션에 포커스를 맞춥니다.
        v, _ := g.SetCurrentView(s.options[s.currentOpt])
        v.Highlight = true
    }

    return nil
}

 

Select에서 Enter를 누르면 위의 처리가 실행되어, 옵션마다 뷰 좌표를 증가시키면서 그립니다. 방법 자체는 단순하지만, 코드량과 처리량이 많은 것이 문제네요.

 

간단히 요약하면,

  • 옵션 리스트를 Enter로 동적으로 생성하려면 옵션마다 뷰를 생성하고, 이동과 선택의 키 바인딩을 추가하는 처리가 필요하다.
  • 이동은 이전 뷰의 포커스를 해제하고, 다음 뷰에 포커스를 맞추는 처리가 필요하다.
  • 옵션 리스트에서 Enter를 누르면 선택한 옵션을 반영하고, 리스트를 닫는 처리가 필요하다.

이런 것들을 생각하고 구현해야 합니다.

 

꽤 힘든 작업입니다.

 

차라리 다른 라이브러리를 사용하는 것이 더 낫지 않을까 생각도 듭니다.

gocui의 앞으로와 그 대안

gocui의 앞으로에 대해서인데요, 제작자 본인이 별로 활동하지 않는 것 같아서, 풀 리퀘스트가 머지될 기미도 없고, 새로운 기능이 추가될 것을 크게 기대할 수는 없을 것 같습니다.

 

gocui의 대안이 될 만한 것을 몇 개 찾아봤는데, 그중 가장 괜찮아 보이는 것이 tview였습니다.

 

gocui와 비교해서 tview는 아직 나온 지 몇년 안된 거 같고, 개발이 꽤 활발하며 폼이나 테이블 등의 컴포넌트가 기본 탑재되어 있어서 리치한 CUI 라이브러리입니다.