Files
yoyo/internal/tui/model.go
titor b04092fd68 feat: 发布 v1.0.0-beta 版本
- 添加 ASCII 艺术 Logo 带渐变效果
- 改造输入框使用 ::: 紫色分隔符
- 改造信息栏合并显示语言/模型/记录数
- 添加 Spinner 翻译状态动画
- 优化翻译卡片样式
- 版本号三方同步规则
2026-04-07 07:12:00 +08:00

419 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/spinner"
"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/content"
"github.com/titor/fanyi/internal/translator"
)
var supportedLangs = []string{"zh-CN", "en-US", "ja", "ko", "zh-TW", "es", "fr", "de"}
var logoPattern = "l_ _ _____ _____ " + "\n" +
"( \\/ ( _ ( _ ) " + "\n" +
" \\ / )(_)( )(_)( " + "\n" +
" (__)(_____(_____((() [v" + content.Version + "]"
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
spinner spinner.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 = "在这里输入你要翻译的内容。Enter 翻译Ctrl+J 启用换行)"
ta.Focus()
ta.Prompt = ""
ta.ShowLineNumbers = false
ta.SetWidth(60)
ta.SetHeight(5)
ta.SetStyles(textarea.DefaultStyles(true))
ta.KeyMap.InsertNewline.SetKeys("ctrl+j")
ta.KeyMap.InsertNewline.SetEnabled(true)
vp := viewport.New(viewport.WithWidth(50), viewport.WithHeight(20))
vp.SetContent("")
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6"))
return tea.NewProgram(model{
config: cfg,
translator: t,
messages: make([]ChatMessage, 0),
input: ta,
viewport: vp,
spinner: sp,
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 m.spinner.Tick
}
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)
m.spinner, cmd = m.spinner.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 {
contentWidth := m.viewport.Width() - 2
timeStr := msg.Timestamp.Format("15:04")
timeLabel := CardTimeStyle.Render("# " + timeStr)
inputText := lipgloss.NewStyle().
Width(contentWidth).
Render(msg.Input)
inputBlock := lipgloss.NewStyle().
Background(lipgloss.Color("#1A1A1A")).
Padding(1, 3).
Width(m.viewport.Width()).
Render(lipgloss.JoinVertical(
lipgloss.Top,
timeLabel,
inputText,
))
var outputBlock string
if msg.Error != "" {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Error)
outputBlock = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F87171")).
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
} else {
outputContent := lipgloss.NewStyle().
Width(m.viewport.Width() - 2).
Render(msg.Output)
outputBlock = lipgloss.NewStyle().
Padding(0, 3, 1, 3).
Width(m.viewport.Width()).
Render(outputContent)
}
footerContent := lipgloss.NewStyle().
MarginLeft(3).
Render(lipgloss.JoinHorizontal(
lipgloss.Left,
CardFooterIconStyle.Render("▣"),
CardFooterTextStyle.Render(msg.Model+" · "+strconv.Itoa(msg.Tokens)),
))
return CardStyle.Render(
lipgloss.JoinVertical(
lipgloss.Top,
inputBlock,
outputBlock,
footerContent,
),
) + "\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()
infoBar := m.renderInfoBar()
spinnerView := m.renderSpinner()
content := header + "\n" + messages + inputArea + infoBar + spinnerView
v := tea.NewView(content)
v.AltScreen = true
return v
}
func gradientText(text string, startColor, endColor string) string {
startR, startG, startB := parseHexColor(startColor)
endR, endG, endB := parseHexColor(endColor)
lines := strings.Split(text, "\n")
if len(lines) == 0 {
return text
}
var result []string
for _, line := range lines {
if len(line) == 0 {
result = append(result, line)
continue
}
var coloredLine string
for i, char := range line {
ratio := float64(i) / float64(len(line)-1)
r := int(float64(startR) + float64(endR-startR)*ratio)
g := int(float64(startG) + float64(endG-startG)*ratio)
b := int(float64(startB) + float64(endB-startB)*ratio)
coloredLine += fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, string(char))
}
result = append(result, coloredLine)
}
return strings.Join(result, "\n")
}
func parseHexColor(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#")
r, _ := strconv.ParseInt(hex[0:2], 16, 64)
g, _ := strconv.ParseInt(hex[2:4], 16, 64)
b, _ := strconv.ParseInt(hex[4:6], 16, 64)
return int(r), int(g), int(b)
}
func (m model) renderHeader() string {
title := gradientText(logoPattern, "#8B5CF6", "#EC4899")
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-29-len(right)-1) + right)
}
func (m model) renderInputArea() string {
inputView := m.input.View()
separator := lipgloss.NewStyle().
Foreground(lipgloss.Color("#8B5CF6")).
Render(":::")
return "\n" + separator + "\n" + inputView + "\n"
}
func (m model) renderInfoBar() string {
var recordCount int
if m.translator != nil && m.translator.GetCache() != nil {
stats, _ := m.translator.GetCache().Stats(context.Background())
if stats != nil {
recordCount = stats.TotalRecords
}
}
var countInfo string
if recordCount == 0 {
countInfo = "暂无记录"
} else {
countInfo = fmt.Sprintf("%d条", recordCount)
}
sep := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(" ")
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Render(":::")
lang := lipgloss.NewStyle().Foreground(lipgloss.Color("#F87171")).Render(m.targetLang)
model := lipgloss.NewStyle().Foreground(lipgloss.Color("#FAFAFA")).Render(getModelName(m.config))
count := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(countInfo)
result := separator + sep + lang + sep + model + sep + count
if m.loading {
result += sep + m.spinner.View() + " 翻译中..."
}
return result + "\n"
}
func (m model) renderSpinner() string {
return ""
}
func (m model) getStatusText() string {
if m.loading {
return "翻译中..."
}
return "就绪"
}