feat: 升级到 lipgloss/bubbletea v2,实现翻译卡片组件

- 升级 charm.land/lipgloss/v2 v1.1.0 -> v2.0.2
- 升级 charm.land/bubbletea/v2 v1.3.10 -> v2.0.2
- 升级 charm.land/bubbles/v2 -> v2.1.0
- 新增翻译卡片组件:元信息行(Tokens/耗时/模型)、用户输入(碳黑背景)、翻译结果
- 卡片组件间距 5px
- 重构 model.go 适配 v2 API
- 更新 keys.go, messages.go, styles.go
This commit is contained in:
2026-04-07 04:47:58 +08:00
parent 18b191d10d
commit 217db90cfa
10 changed files with 865 additions and 287 deletions

View File

@@ -1,244 +0,0 @@
package tui
import (
"context"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/translator"
)
type model struct {
config *config.Config
translator *translator.Translator
textArea textarea.Model
result string
errMsg string
targetLang string
langIndex int
loading bool
width int
height int
}
type translateMsg struct {
result string
err error
}
var (
headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D9FF")).
Bold(true)
dividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00D9FF"))
resultStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#98FB98")).
Background(lipgloss.Color("#0D1B2A"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF6B6B")).
Background(lipgloss.Color("#1A1A2E"))
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888"))
statusBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#1F2937"))
langStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FBBF24")).
Bold(true)
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
targetLang := "zh-CN"
if cfg != nil && cfg.DefaultTargetLang != "" {
targetLang = cfg.DefaultTargetLang
}
ta := textarea.New()
ta.Placeholder = "输入要翻译的文本..."
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetHeight(3)
ta.FocusedStyle.Base = lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A2E")).
Foreground(lipgloss.Color("#FAFAFA"))
return tea.NewProgram(model{
config: cfg,
translator: t,
textArea: ta,
targetLang: targetLang,
})
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateTextAreaWidth()
case translateMsg:
m.loading = false
if msg.err != nil {
m.errMsg = msg.err.Error()
m.result = ""
} else {
m.result = msg.result
m.errMsg = ""
}
m.updateTextAreaHeight()
return m, nil
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
if msg.Alt {
m.textArea.InsertString("\n")
m.updateTextAreaHeight()
return m, nil
}
if m.loading {
return m, nil
}
text := m.textArea.Value()
if text == "" {
return m, nil
}
m.loading = true
m.errMsg = ""
return m, m.doTranslate(text, m.targetLang)
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyCtrlL:
m.textArea.SetValue("")
m.result = ""
m.errMsg = ""
return m, nil
case tea.KeyCtrlT:
m.langIndex = (m.langIndex + 1) % len(supportedLangs)
m.targetLang = supportedLangs[m.langIndex]
return m, nil
case tea.KeyEsc:
return m, tea.Quit
}
m.textArea, cmd = m.textArea.Update(msg)
m.updateTextAreaHeight()
return m, cmd
default:
m.textArea, cmd = m.textArea.Update(msg)
m.updateTextAreaHeight()
}
return m, cmd
}
func (m model) updateTextAreaWidth() {
if m.width > 0 {
margin := 4
width := m.width - margin
if width < 20 {
width = 60
}
m.textArea.SetWidth(width)
}
}
func (m model) updateTextAreaHeight() {
lines := strings.Count(m.textArea.Value(), "\n") + 1
if lines < 1 {
lines = 1
}
if lines > 7 {
lines = 7
}
if lines < 3 {
lines = 3
}
m.textArea.SetHeight(lines)
}
func (m model) doTranslate(text, toLang string) tea.Cmd {
return func() tea.Msg {
result, err := m.translator.Translate(
context.Background(),
text,
&translator.TranslateOptions{
ToLang: toLang,
PromptName: "simple",
},
)
if err != nil {
return translateMsg{err: err}
}
return translateMsg{result: result.Translated}
}
}
func (m model) View() string {
margin := " "
resultBox := m.renderResult()
return "\n" +
margin + headerStyle.Render("YOYO翻译") + "\n" +
margin + dividerStyle.Render(getDivider(m.width-2)) + "\n\n" +
margin + m.textArea.View() + "\n\n" +
margin + resultBox +
margin + dividerStyle.Render(getDivider(m.width-2)) + "\n" +
m.renderStatusBar()
}
func getDivider(width int) string {
if width < 10 {
width = 40
}
result := ""
for i := 0; i < width-4; i++ {
result += "─"
}
return result
}
func (m model) renderResult() string {
if m.loading {
return loadingStyle.Render("正在翻译...") + "\n\n"
}
if m.errMsg != "" {
return errorStyle.Render("错误: "+m.errMsg) + "\n\n"
}
if m.result == "" {
return helpStyle.Render("翻译结果将显示在这里...") + "\n\n"
}
return resultStyle.Render(m.result) + "\n\n"
}
func (m model) renderStatusBar() string {
width := m.width - 4
if width < 30 {
width = 60
}
langInfo := langStyle.Render("目标: " + m.targetLang)
hint := helpStyle.Render("按 / 显示命令")
return " " + statusBarStyle.Render(" "+langInfo+" ") + " " + hint
}

48
internal/tui/keys.go Normal file
View File

@@ -0,0 +1,48 @@
package tui
import (
"charm.land/bubbles/v2/key"
)
type KeyMap struct {
Quit key.Binding
Clear key.Binding
SwitchLang key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
ScrollTop key.Binding
ScrollBottom key.Binding
}
func NewKeyMap() KeyMap {
return KeyMap{
Quit: key.NewBinding(
key.WithKeys("ctrl+c", "esc"),
key.WithHelp("Ctrl+C", "退出"),
),
Clear: key.NewBinding(
key.WithKeys("ctrl+l"),
key.WithHelp("Ctrl+L", "清空输入"),
),
SwitchLang: key.NewBinding(
key.WithKeys("ctrl+t"),
key.WithHelp("Ctrl+T", "切换语言"),
),
ScrollUp: key.NewBinding(
key.WithKeys("up", "ctrl+up"),
key.WithHelp("↑/Ctrl+↑", "上滚"),
),
ScrollDown: key.NewBinding(
key.WithKeys("down", "ctrl+down"),
key.WithHelp("↓/Ctrl+↓", "下滚"),
),
ScrollTop: key.NewBinding(
key.WithKeys("home"),
key.WithHelp("Home", "顶部"),
),
ScrollBottom: key.NewBinding(
key.WithKeys("end"),
key.WithHelp("End", "底部"),
),
}
}

21
internal/tui/messages.go Normal file
View File

@@ -0,0 +1,21 @@
package tui
import "time"
type ChatMessage struct {
ID string
Input string
Output string
FromLang string
ToLang string
Model string
Tokens int
Timestamp time.Time
Error string
}
type ChatHistory struct {
Messages []ChatMessage
scrollPos int
totalLines int
}

369
internal/tui/model.go Normal file
View File

@@ -0,0 +1,369 @@
package tui
import (
"context"
"fmt"
"strings"
"time"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/translator"
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
type translateMsg struct {
result string
tokens int
err error
}
type model struct {
config *config.Config
translator *translator.Translator
messages []ChatMessage
input textarea.Model
viewport viewport.Model
keys KeyMap
targetLang string
langIndex int
loading bool
lastInput string
width int
height int
inputHeight int
}
func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program {
keys := NewKeyMap()
ta := textarea.New()
ta.Placeholder = "输入要翻译的文本... (Ctrl+J 换行)"
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetWidth(60)
ta.SetHeight(5)
ta.SetStyles(textarea.DefaultStyles(false))
ta.KeyMap.InsertNewline.SetEnabled(true)
vp := viewport.New(viewport.WithWidth(50), viewport.WithHeight(20))
vp.SetContent("")
return tea.NewProgram(model{
config: cfg,
translator: t,
messages: make([]ChatMessage, 0),
input: ta,
viewport: vp,
keys: keys,
targetLang: getDefaultLang(cfg),
})
}
func getDefaultLang(cfg *config.Config) string {
if cfg != nil && cfg.DefaultTargetLang != "" {
return cfg.DefaultTargetLang
}
return "zh-CN"
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateLayout()
case translateMsg:
m.loading = false
if msg.err != nil {
m.addErrorMessage(msg.err.Error())
} else {
m.addSuccessMessage(msg.result, msg.tokens)
}
m.updateViewportContent()
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+l":
m.input.Reset()
case "ctrl+t":
m.langIndex = (m.langIndex + 1) % len(supportedLangs)
m.targetLang = supportedLangs[m.langIndex]
case "enter":
text := strings.TrimSpace(m.input.Value())
if text != "" && !m.loading {
m.input.Reset()
m.lastInput = text
m.loading = true
cmds = append(cmds, m.doTranslate(text, m.targetLang))
}
case "alt+up":
m.viewport.ScrollUp(3)
case "alt+down":
m.viewport.ScrollDown(3)
case "home":
m.viewport.GotoTop()
case "end":
m.viewport.GotoBottom()
}
}
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *model) addSuccessMessage(result string, tokens int) {
msg := ChatMessage{
Input: m.lastInput,
Output: result,
ToLang: m.targetLang,
Model: getModelName(m.config),
Tokens: tokens,
Timestamp: time.Now(),
}
m.messages = append(m.messages, msg)
}
func (m *model) addErrorMessage(err string) {
msg := ChatMessage{
Input: m.lastInput,
Error: err,
ToLang: m.targetLang,
Timestamp: time.Now(),
}
m.messages = append(m.messages, msg)
}
func (m *model) doTranslate(text, toLang string) tea.Cmd {
return func() tea.Msg {
result, err := m.translator.Translate(
context.Background(),
text,
&translator.TranslateOptions{
ToLang: toLang,
PromptName: "simple",
},
)
if err != nil {
return translateMsg{err: err}
}
tokens := 0
if result.Usage != nil {
tokens = result.Usage.TotalTokens
}
return translateMsg{result: result.Translated, tokens: tokens}
}
}
func (m *model) updateLayout() {
if m.width <= 0 || m.height <= 0 {
return
}
contentWidth := m.width - 4
if contentWidth < 20 {
contentWidth = 60
}
m.input.SetWidth(contentWidth)
m.viewport.SetWidth(contentWidth)
m.viewport.SetHeight(m.height - 12)
if m.viewport.Height() < 5 {
m.viewport.SetHeight(10)
}
m.updateViewportContent()
}
func (m *model) updateViewportContent() {
var b strings.Builder
for _, msg := range m.messages {
b.WriteString(m.renderTranslationCard(msg))
}
m.viewport.SetContent(b.String())
m.viewport.GotoBottom()
}
func (m *model) renderTranslationCard(msg ChatMessage) string {
metaContent := lipgloss.JoinHorizontal(
lipgloss.Left,
CardMetaStyle.Render(fmt.Sprintf("Tokens: %d", msg.Tokens)),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("耗时: %s", msg.Timestamp.Format("15:04:05"))),
CardMetaSeparatorStyle,
CardMetaStyle.Render(fmt.Sprintf("模型: %s", msg.Model)),
)
metaBlock := lipgloss.NewStyle().
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#374151")).
Width(m.viewport.Width() - 2).
Render(metaContent)
inputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Input)
inputBlock := lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Width(m.viewport.Width()).
Render(inputContent)
var outputBlock string
if msg.Error != "" {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Error)
outputBlock = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Width(m.viewport.Width()).
Render(outputContent)
} else {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Output)
outputBlock = lipgloss.NewStyle().
Width(m.viewport.Width()).
Render(outputContent)
}
return lipgloss.JoinVertical(
lipgloss.Top,
metaBlock,
inputBlock,
outputBlock,
) + "\n"
}
func getModelName(cfg *config.Config) string {
if cfg != nil && cfg.DefaultModel != "" {
return cfg.DefaultModel
}
return "gpt-3.5-turbo"
}
func (m model) View() tea.View {
if m.width == 0 {
return tea.NewView("正在加载...")
}
header := m.renderHeader()
messages := m.viewport.View()
inputArea := m.renderInputArea()
statusBar := m.renderStatusBar()
content := header + "\n" + messages + inputArea + statusBar
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m model) renderHeader() string {
title := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Bold(true).
Render("✦ YOYO 翻译")
width := m.width - 4
if width < 20 {
width = 60
}
right := lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Render("[Ctrl+C 退出]")
return lipgloss.NewStyle().
Width(width).
Render(title + strings.Repeat(" ", width-len(title)-len(right)-1) + right)
}
func (m model) renderInputArea() string {
inputView := m.input.View()
container := lipgloss.NewStyle().
Width(m.input.Width() + 1).
BorderStyle(lipgloss.Border{
Top: "─",
Bottom: "─",
Left: "│",
Right: "│",
}).
BorderForeground(lipgloss.Color("#60A5FA"))
return "\n" + container.Render(inputView) + "\n"
}
func (m model) renderStatusBar() string {
langInfo := "目标: " + m.targetLang
modelInfo := "模型: " + getModelName(m.config)
tokensInfo := "Tokens: -"
if len(m.messages) > 0 {
lastMsg := m.messages[len(m.messages)-1]
if lastMsg.Tokens > 0 {
tokensInfo = fmt.Sprintf("Tokens: %d", lastMsg.Tokens)
}
}
statusDot := StatusDotStyle.Render("●")
if m.loading {
statusDot = LoadingStyle.Render("○")
}
sep := StatusItemStyle.Render(" │ ")
width := m.width - 4
if width < 30 {
width = 60
}
status := StatusItemStyle.Render(langInfo) +
sep + StatusItemStyle.Render(modelInfo) +
sep + StatusItemStyle.Render(tokensInfo) +
sep + statusDot + " " + StatusValueStyle.Render(m.getStatusText())
return lipgloss.NewStyle().
Width(width).
Background(lipgloss.Color("#1F2937")).
Render(" " + status)
}
func (m model) getStatusText() string {
if m.loading {
return "翻译中..."
}
return "就绪"
}

71
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,71 @@
package tui
import (
"charm.land/lipgloss/v2"
)
var (
HeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true).
Padding(0, 1)
InputLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA")).
Bold(true)
OutputLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#34D399")).
Bold(true)
InputTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#E5E7EB"))
OutputTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
ErrorTextStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Bold(true)
TimestampStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280"))
DividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#374151"))
StatusBarStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#1F2937")).
Foreground(lipgloss.Color("#9CA3AF"))
StatusItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#9CA3AF"))
StatusValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
LoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#60A5FA"))
StatusDotStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#34D399"))
CardStyle = lipgloss.NewStyle().
MarginBottom(5)
CardMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Padding(0, 1)
CardMetaSeparatorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#374151")).
Render(" │ ")
CardInputStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Foreground(lipgloss.Color("#E5E7EB")).
Padding(0, 1)
CardOutputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF"))
)