diff --git a/changelog.md b/changelog.md index 2efe68e..de62855 100644 --- a/changelog.md +++ b/changelog.md @@ -441,4 +441,31 @@ yoyo onboard --force - View() 方法返回 `tea.View` 类型 - KeyMsg 改为 KeyPressMsg,使用 `msg.String()` 判断键位 -**讨论记录**: [taolun.md#版本-0.8.1-翻译结果卡片组件设计](taolun.md#版本-081---翻译结果卡片组件设计) \ No newline at end of file +**讨论记录**: [taolun.md#版本-0.8.1-翻译结果卡片组件设计](taolun.md#版本-081---翻译结果卡片组件设计) + +--- + +## v1.0.0-beta (2026-04-07) + +### 新功能 +- ASCII艺术Logo标题,带紫色→粉色渐变效果 +- 输入框改造: + - 使用 `:::` 紫色分隔符替代上下边框 + - Ctrl+J 换行功能 +- 信息栏改造: + - 合并显示:语言、模型名、缓存记录数 + - 添加翻译状态 Spinner 动画 (MiniDot) +- 翻译结果卡片优化: + - 底部 `▣` 图标左侧边距从3减少到2 + - `▣` 与文字间距从3减少到2 + +### 技术细节 +- 使用 lipgloss 实现 True Color 渐变效果 +- 使用 charmbracelet/bubbles spinner 组件实现加载动画 +- 版本号显示在Logo右侧 [v1.0.0-beta] +- 动态调整 viewport 高度适应终端 + +### 版本号规则 +- 版本号需与 git 标签、changelog.md 中的版本号保持三方同步 + +**讨论记录**: [taolun.md#版本-100-beta-Logo和信息栏改造](taolun.md#版本-100-beta-logo和信息栏改造) \ No newline at end of file diff --git a/internal/content/content.go b/internal/content/content.go index cee1250..a69ffd0 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -5,7 +5,7 @@ import ( ) const ( - Version = "1.0.0" + Version = "1.0.0-beta" ) func DetectLanguage(text string) string { diff --git a/internal/translator/translator.go b/internal/translator/translator.go index ecd0f45..8b3bb25 100644 --- a/internal/translator/translator.go +++ b/internal/translator/translator.go @@ -20,6 +20,10 @@ type Translator struct { cache cache.Cache } +func (t *Translator) GetCache() cache.Cache { + return t.cache +} + // NewTranslator 创建翻译器实例 func NewTranslator(config *config.Config, provider provider.Provider) *Translator { translator := &Translator{ diff --git a/internal/tui/model.go b/internal/tui/model.go index d0a62e0..2a92524 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,19 +3,27 @@ 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 @@ -29,6 +37,7 @@ type model struct { messages []ChatMessage input textarea.Model viewport viewport.Model + spinner spinner.Model keys KeyMap targetLang string @@ -45,25 +54,31 @@ func NewApp(cfg *config.Config, t *translator.Translator) *tea.Program { keys := NewKeyMap() ta := textarea.New() - ta.Placeholder = "输入要翻译的文本... (Ctrl+J 换行)" + ta.Placeholder = "在这里输入你要翻译的内容。(Enter 翻译,Ctrl+J 启用换行)" ta.Focus() ta.Prompt = "" ta.ShowLineNumbers = false ta.SetWidth(60) ta.SetHeight(5) - ta.SetStyles(textarea.DefaultStyles(false)) + 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), }) @@ -77,7 +92,7 @@ func getDefaultLang(cfg *config.Config) string { } func (m model) Init() tea.Cmd { - return nil + return m.spinner.Tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -135,6 +150,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.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...) } @@ -213,34 +231,24 @@ func (m *model) updateViewportContent() { } 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)), - ) + contentWidth := m.viewport.Width() - 2 - metaBlock := lipgloss.NewStyle(). - BorderStyle(lipgloss.Border{ - Top: "─", - Bottom: "─", - Left: "│", - Right: "│", - }). - BorderForeground(lipgloss.Color("#374151")). - Width(m.viewport.Width() - 2). - Render(metaContent) + timeStr := msg.Timestamp.Format("15:04") + timeLabel := CardTimeStyle.Render("# " + timeStr) - inputContent := lipgloss.NewStyle(). - Width(m.viewport.Width() - 2). + inputText := lipgloss.NewStyle(). + Width(contentWidth). Render(msg.Input) inputBlock := lipgloss.NewStyle(). Background(lipgloss.Color("#1A1A1A")). + Padding(1, 3). Width(m.viewport.Width()). - Render(inputContent) + Render(lipgloss.JoinVertical( + lipgloss.Top, + timeLabel, + inputText, + )) var outputBlock string if msg.Error != "" { @@ -249,6 +257,7 @@ func (m *model) renderTranslationCard(msg ChatMessage) string { Render(msg.Error) outputBlock = lipgloss.NewStyle(). Foreground(lipgloss.Color("#F87171")). + Padding(0, 3, 1, 3). Width(m.viewport.Width()). Render(outputContent) } else { @@ -256,16 +265,25 @@ func (m *model) renderTranslationCard(msg ChatMessage) string { 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, - metaBlock, inputBlock, outputBlock, + footerContent, ), ) + "\n" } @@ -285,19 +303,55 @@ func (m model) View() tea.View { header := m.renderHeader() messages := m.viewport.View() inputArea := m.renderInputArea() - statusBar := m.renderStatusBar() + infoBar := m.renderInfoBar() + spinnerView := m.renderSpinner() - content := header + "\n" + messages + inputArea + statusBar + 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 := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#8B5CF6")). - Bold(true). - Render("✦ YOYO 翻译") + title := gradientText(logoPattern, "#8B5CF6", "#EC4899") width := m.width - 4 if width < 20 { @@ -310,57 +364,50 @@ func (m model) renderHeader() string { return lipgloss.NewStyle(). Width(width). - Render(title + strings.Repeat(" ", width-len(title)-len(right)-1) + right) + Render(title + strings.Repeat(" ", width-29-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" + separator := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")). + Render(":::") + return "\n" + separator + "\n" + 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) +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 } } - statusDot := StatusDotStyle.Render("●") + 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 { - statusDot = LoadingStyle.Render("○") + result += sep + m.spinner.View() + " 翻译中..." } - sep := StatusItemStyle.Render(" │ ") + return result + "\n" +} - 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) renderSpinner() string { + return "" } func (m model) getStatusText() string { diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 564e397..a8fd173 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -51,7 +51,7 @@ var ( Foreground(lipgloss.Color("#34D399")) CardStyle = lipgloss.NewStyle(). - MarginBottom(5) + MarginBottom(1) CardMetaStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6B7280")). @@ -68,4 +68,17 @@ var ( CardOutputStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFFFF")) + + CardTimeStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + MarginBottom(1) + + CardFooterIconStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")). + AlignVertical(lipgloss.Center) + + CardFooterTextStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + MarginLeft(2). + AlignVertical(lipgloss.Center) ) diff --git a/taolun.md b/taolun.md index aefe430..0659837 100644 --- a/taolun.md +++ b/taolun.md @@ -758,4 +758,40 @@ ta.SetHeight(5) // 固定高度,不动态调整 **下一步**: 实现组件代码 **关联文档**: -- [changelog.md#0.8.1](changelog.md#081) \ No newline at end of file +- [changelog.md#0.8.1](changelog.md#081) + +--- + +### [2026-04-07] 版本 1.0.0-beta - Logo和信息栏改造 + +**原因**: 用户希望改进TUI界面的视觉效果,使标题更独特,输入框和信息栏更美观 + +**分析**: +- 原标题 "✦ YOYO 翻译" 过于简单 +- 输入框需要更好的视觉分隔 +- 需要添加翻译状态动画 + +**解决方案**: +1. **标题Logo**: + - 使用ASCII艺术 "l_ _ _____ _____" + - 实现紫色→粉色渐变效果 (ANSI True Color) + - 右侧显示版本号 [v1.0.0-beta] + +2. **输入框改造**: + - 去掉边框 + - 上下使用紫色 `:::` 分隔符 + - Ctrl+J 启用换行 + +3. **信息栏改造**: + - 合并显示:语言(红色) + 模型名(白色) + 缓存记录(碳黑) + - 翻译时显示 Spinner 动画 (MiniDot) + +4. **翻译卡片优化**: + - `▣` 图标边距调整 + +**版本号规则**: +- 版本号需与 git 标签、changelog.md 中的版本号保持三方同步 +- 遵循语义化版本:主版本.次版本.修订版本 +- beta版使用 `-beta` 后缀 + +**关联版本**: [changelog.md#1.0.0-beta](changelog.md#100-beta-2026-04-07) \ No newline at end of file