@@ -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 ( fals e) )
ta . SetStyles ( textarea . DefaultStyles ( tru e) )
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 {
metaC ontent := 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 ) ) ,
)
c ontentWidth := m . viewport . Width ( ) - 2
metaBlock := lipgloss . NewStyle ( ) .
BorderStyle ( lipgloss . Borde r {
Top : "─" ,
Bottom : "─" ,
Left : "│" ,
Right : "│" ,
} ) .
BorderForeground ( lipgloss . Color ( "#374151" ) ) .
Width ( m . viewport . Width ( ) - 2 ) .
Render ( metaContent )
timeStr := msg . Timestamp . Format ( "15:04" )
timeLabel := CardTimeStyle . Render ( "# " + timeSt r )
inputConten t := lipgloss . NewStyle ( ) .
Width ( m . viewport . Width ( ) - 2 ) .
inputTex t := 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 ( )
status Bar := m . renderStatus Bar ( )
info Bar := m . renderInfo Bar ( )
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 ) renderStatus Bar ( ) 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 ) renderInfo Bar ( ) 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 {
statusDo t = LoadingStyle . Render ( "○" )
resul t + = 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 {