简介

The fun, functional and stateful way to build terminal apps. A Go framework based on The Elm Architecture. Bubble Tea is well-suited for simple and complex terminal applications, either inline, full-window, or a mix of both.

BubbleTea是一个轻量级的TUI(Terminal User Interface)框架,在其基础上,可以轻松的开发一些好看的命令行工具。

Bubble符合Elm框架(一种函数式的前端框架)的标准。

基础

首先熟悉一下官方文档中的例子。我们在这里创建了一个简单的选项表,模拟让用户选择不同食物。

简单的list实例

程序入口

func main() {
    // 此处的initialModel()返回一个Model
    model := initialModel()
	p := tea.NewProgram(initialModel())
	if _, err := p.Run(); err != nil {
		fmt.Printf("Alas, there's been an error: %v", err)
		os.Exit(1)
	}
}

tea.NewProgram()创建一个program,然后使用p.Run()来启动程序。tea.NewProgram()的参数通常是Model类型。

Model

Model是一个接口,接口定义如下

// Model contains the program's state as well as its core functions.
type Model interface {
	Init() Cmd
	Update(Msg) (Model, Cmd)
	View() string
}

也就是说,只要我们的类型实现了Init(), Update(Msg), View()方法,就可以把其作为Model参数传入tea.NewProgram()中。

创建一个model结构体,并实现Model接口。

type model struct {
	choices  []string
	cursor   int
	selected map[int]struct{}
}

// 首先被调用的函数,返回optional
// 如果不执行初始命令,则返回 nil。
func (m model) Init() tea.Cmd {
	return nil
}

// 收到消息时调用Update()。用它来检查消息
// Update()将更新Model或者执行指令
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c", "q":
			return m, tea.Quit
		case "up", "k":
			if m.cursor > 0 {
				m.cursor--
			}
		case "down", "j":
			if m.cursor < len(m.choices)-1 {
				m.cursor++
			}
		case "enter", " ":
			_, ok := m.selected[m.cursor]
			if ok {
				delete(m.selected, m.cursor)
			} else {
				m.selected[m.cursor] = struct{}{}
			}
		}
	}
	return m, nil
}


// View()用来渲染UI,每次调用Update()后都会调用View()
func (m model) View() string {
	s := "What should we buy at the market?\n\n"
	for i, choice := range m.choices {
		cursor := " "
		if m.cursor == i {
			cursor = ">"
		}
		checked := " "
		if _, ok := m.selected[i]; ok {
			checked = "x"
		}
		s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
	}
	s += "\nPress q to quit.\n"
	return s
}

Update()接受的msg可以是任何类型的,通常使用断言进行类型判断,从而对不同类型的msg做处理。在例子中,tea.KeyMsg指的是是键盘输入,里面对应不同的类型做了不同逻辑判断。

View()是渲染UI的函数,这里通过对m。curosr为的值进行判断,从而显示游标>的位置。

Initialization函数

实例代码中还提供了一个Initialization函数,用来初始化model中的数据,但是这也不是必须的。我们可以通过很多别的方法创建、编辑model中的值

func initialModel() model {
	return model{
		// Our to-do list is a grocery list
		choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},

		// A map which indicates which choices are selected. We're using
		// the  map like a mathematical set. The keys refer to the indexes
		// of the `choices` slice, above.
		selected: make(map[int]struct{}),
	}
}

当然,实例中的TUI过于简陋,实际上BubbleTea开发的TUI都很漂亮。BubbleTea的GitHub页面提供了非常多的实例,供开发者学习。

进度条(官方用例)

一个渐变色的进度条(animated),动画过渡平滑

简单的list实例

另一种实现方式(静态的),每次update才渲染一次。

静态进度条

实现(animated)

全局变量

const (
	padding  = 2 // 填充长度
	maxWidth = 80 // 进度条宽度
)

var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render // 帮助字段样式

type tickMsg time.Time // msg类型

model定义

// 引入了"github.com/charmbracelet/bubbles/progress"中的progress这个model
type model struct {
	progress progress.Model
}

github.com/charmbracelet/bubbles/ 本身也有不少已经实现的model,比如list, progress, spinner, paginator, spinner, table等,可以快速构建一个TUI应用。

Init函数实现

func (m model) Init() tea.Cmd {
	return tickCmd()
}

这里返回了tickCmd()函数,这个函数定义如下:

func tickCmd() tea.Cmd {
	return tea.Tick(time.Second*1, func(t time.Time) tea.Msg {
		return tickMsg(t)
	})
}

后面的函数作为参数传入,目的只是返回一个time.time类型,这里我其实不理解为什么开发者会把这个函数设计的这么复杂,在tea.Tick的实现中,有大段的话解释,大致内容如下:

  • Tick产生的定时器独立于系统时间
  • Tick函数需要传入一个时间间隔和一个函数作为参数。这个函数会返回一个消息,在这个消息中包含了定时器触发的时间
  • Tick函数只会发送单个消息,并不会自动按照间隔发送多个消息。为了实现定期触发消息,需要在接收到消息后返回另一个 Tick Cmd。

也就是说,这样的写法可以当成固定的组合用法

type TickMsg time.Time

func doTick() Cmd {
	return Tick(time.Second, func(t time.Time) Msg {
		return TickMsg(t)
	})
}

func (m model) Init() Cmd {
	// Start ticking.
	return doTick()
}

func (m model) Update(msg Msg) (Model, Cmd) {
	switch msg.(type) {
	case TickMsg:
		// Return your Tick command again to loop.
		return m, doTick()
	}
	return m, nil
	}

Update函数

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		return m, tea.Quit

	case tea.WindowSizeMsg:
		m.progress.Width = msg.Width - padding*2 - 4
		if m.progress.Width > maxWidth {
			m.progress.Width = maxWidth
		}
		return m, nil

	case tickMsg:
		if m.progress.Percent() == 1.0 {
			return m, tea.Quit
		}

		// Note that you can also use progress.Model.SetPercent to set the
		// percentage value explicitly, too.
		cmd := m.progress.IncrPercent(0.25)
		return m, tea.Batch(tickCmd(), cmd)

	// FrameMsg is sent when the progress bar wants to animate itself
	case progress.FrameMsg:
		progressModel, cmd := m.progress.Update(msg)
		m.progress = progressModel.(progress.Model)
		return m, cmd

	default:
		return m, nil
	}
}

在Update函数中,分别处理了窗口大小事件,点按键盘事件,时间跳动事件等。

View方法

func (m model) View() string {
	pad := strings.Repeat(" ", padding)
	return "\n" +
		pad + m.progress.View() + "\n\n" +
		pad + helpStyle("Press any key to quit")
}

process的方法的基础上,添加了占位符,添加了帮助信息。

命令行迷宫

package tui

import (
	"strings"
	"vimMaze/maze"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"
)

const (
	wall = iota
	path
	person
	end
	key
)

// 设置帮助字段样式,砖块样式
var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render
var brickStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#B6482F")).Render

type model struct {
	Maze maze.Maze
}

func (m model) Init() tea.Cmd {
	return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch key := msg.String(); key {
		case "ctrl+c":
			return m, tea.Quit
		case "j":
			m.Maze.GoDown()
			return m, nil
		case "k":
			m.Maze.GoUp()
			return m, nil
		case "h":
			m.Maze.Goleft()
			return m, nil
		case "l":
			m.Maze.Goright()
			return m, nil
		}
	case tea.WindowSizeMsg:
		m.Maze = maze.GenerateMaze(min(msg.Height, msg.Width))
		return m, nil
	}

	var cmd tea.Cmd

	return m, cmd
}

func (m model) View() string {
	if m.Maze.Win == true {
		return "You win!🎉" + "\n\n  " + helpStyle("Press ctrl+c to exit")
	}
	var build strings.Builder
	for _, valueRow := range m.Maze.Map {
		for _, valuePoint := range valueRow {
			if valuePoint == wall {
				build.WriteString(brickStyle("░░"))
			} else if valuePoint == path {
				build.WriteString("  ")
			} else if valuePoint == person {
				build.WriteString("🧑")

			} else if valuePoint == end {
				build.WriteString("💰")
			}

		}

		build.WriteString("\n")
	}
	return build.String()
}

效果预览

maze

TODO: 为nmap开发一个简单TUI,方便用户定义参数

可能遥遥无期了