diff --git a/changelog.md b/changelog.md index 702c250..2efe68e 100644 --- a/changelog.md +++ b/changelog.md @@ -42,6 +42,19 @@ | 5 | 快捷键系统 | 退出、清空、切换语言等 | ✅ 已完成 | | 6 | 集成翻译 | 对接现有Translator、加载动画 | ✅ 已完成 | +## TUI界面重构计划 (v0.8.0) - 聊天风格 +| 步骤 | 模块 | 内容 | 状态 | +|------|------|------|------| +| 1 | 模块结构拆分 | 创建 view/components/styles/keys 子目录 | ✅ 已完成 | +| 2 | 消息数据结构 | ChatMessage, ChatGroup 结构定义 | ✅ 已完成 | +| 3 | 消息列表组件 | 可滚动的消息历史展示 (viewport) | ✅ 已完成 | +| 4 | 原文+译文样式 | 区分显示用户输入和翻译结果 | ✅ 已完成 | +| 5 | 固定底部输入框 | textarea + Ctrl+J 换行 + 固定高度5行 | ✅ 已完成 | +| 6 | 状态栏 | 完整信息显示 | ✅ 已完成 | +| 7 | 翻译逻辑集成 | 对接 Translator | ✅ 已完成 | +| 8 | 输入框背景色 | 使用 FocusedStyle/BlurredStyle 设置背景 | ✅ 已完成 | +| 9 | 输入框修复 | 修复Ctrl+J换行后第一行被遮住的问题 | ✅ 已完成 | + ## TUI界面改进计划 (v0.7.0) | 步骤 | 模块 | 内容 | 状态 | |------|------|------|------| @@ -405,4 +418,27 @@ yoyo onboard --force ### 示例版本递增 - `0.0.1` → `0.0.2`:小修复 - `0.0.99` → `0.1.0`:新功能(修订版本溢出) -- `1.2.3` → `2.0.0`:重大架构变更 \ No newline at end of file +- `1.2.3` → `2.0.0`:重大架构变更 + +--- + +## v0.8.1 (2026-04-07) + +### 新功能 +- 使用 lipgloss v2 设计翻译结果卡片组件 +- 卡片包含三部分:元信息行(Tokens/耗时/模型)、用户输入(碳黑背景)、翻译结果 + +### 升级 +- 升级 `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 +- 更新所有模块路径为 `charm.land/xxx/v2` 格式 + +### 技术细节 +- 背景色: #1A1A1A (碳黑色) +- 用户输入区域无边框,纯背景色 +- 组件间距: 5px marginBottom +- View() 方法返回 `tea.View` 类型 +- KeyMsg 改为 KeyPressMsg,使用 `msg.String()` 判断键位 + +**讨论记录**: [taolun.md#版本-0.8.1-翻译结果卡片组件设计](taolun.md#版本-081---翻译结果卡片组件设计) \ No newline at end of file diff --git a/go.mod b/go.mod index 7177348..738e44b 100644 --- a/go.mod +++ b/go.mod @@ -3,38 +3,47 @@ module github.com/titor/fanyi go 1.26.1 require ( - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/charmbracelet/bubbles v1.0.0 + github.com/go-enry/go-enry/v2 v2.9.5 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.37 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-enry/go-enry/v2 v2.9.5 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mattn/go-sqlite3 v1.14.37 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0e1d5ce..99e180a 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,51 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= @@ -42,27 +53,23 @@ github.com/go-enry/go-enry/v2 v2.9.5 h1:HPhAQQHYwJgihL2PxBZiUMFWiROsGwOBdB6/D8zC github.com/go-enry/go-enry/v2 v2.9.5/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -73,8 +80,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -83,31 +90,33 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -120,6 +129,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tui/app.go b/internal/tui/app.go deleted file mode 100644 index 00b85db..0000000 --- a/internal/tui/app.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..29cbe51 --- /dev/null +++ b/internal/tui/keys.go @@ -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", "底部"), + ), + } +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go new file mode 100644 index 0000000..dca8840 --- /dev/null +++ b/internal/tui/messages.go @@ -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 +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..a85bc09 --- /dev/null +++ b/internal/tui/model.go @@ -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 "就绪" +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..564e397 --- /dev/null +++ b/internal/tui/styles.go @@ -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")) +) diff --git a/memory.md b/memory.md index f8876d0..a69da8d 100644 --- a/memory.md +++ b/memory.md @@ -745,4 +745,153 @@ func matchCommand(input string) []command { ### Viewport组件 用于长文本滚动显示,配合scrollbar展示滚动位置。 +``` + +--- + +## TUI输入框踩坑记录 (v0.8.0) + +### 问题1:Ctrl+J换行后第一行被遮住 + +**现象**: +- 按Ctrl+J换行后,第一行内容往上滚动被遮住 +- 光标在新行,但下方显示一个空行 +- 实际渲染了3行,但只显示2行 + +**尝试过的方案**: +1. 移除 lipgloss Width() 限制 - 无效 +2. 设置 SetWidth() 后再 SetHeight() - 无效 +3. 动态计算行数后调用 SetHeight() - 无效 +4. 移除 updateInputHeight() 调用 - 无效 + +**根因分析**: +- textarea 内部使用 viewport 组件管理滚动 +- 每次按键后动态调用 `m.input.SetHeight(lines)` 调整高度 +- 导致 textarea 内部 viewport 滚动位置与渲染不同步 + +**最终解决方案**: +- 放弃动态调整高度的方案 +- 固定 textarea 高度为5行 +- 超过5行时,textarea 内部自动滚动,光标始终可见 + +**关键代码** (`internal/tui/model.go`): +```go +ta.SetWidth(60) +ta.SetHeight(5) // 固定高度,不动态调整 +``` + +### 问题2:输入框背景颜色 + +**解决方案**: +- 使用 `textarea.DefaultStyles()` 获取默认样式 +- 修改 Style.Base 设置背景色 + +**关键代码**: +```go +focusedStyle, blurredStyle := textarea.DefaultStyles() +bgStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#1F2937")). + Foreground(lipgloss.Color("#FAFAFA")) +focusedStyle.Base = bgStyle +blurredStyle.Base = bgStyle +ta.FocusedStyle = focusedStyle +ta.BlurredStyle = blurredStyle +``` + +### 经验总结 + +1. **Bubble Tea的textarea组件**:内部包含viewport,不适合频繁动态调整高度 +2. **固定高度方案**:更稳定,让组件内部控制滚动 +3. **样式设置**:使用 FocusedStyle/BlurredStyle + Style.Base 而非直接设置 Style + +--- + +## Bubble Tea/Lipgloss v2 升级经验 (v0.8.1) + +### 模块路径变更 + +v2 版本全部迁移到 `charm.land` 域名: + +| v1 | v2 | +|----|-----| +| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | +| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | +| `github.com/charmbracelet/bubbles` | `charm.land/bubbles/v2` | + +### View() 方法变更 + +v1: +```go +func (m model) View() string { + return "内容" +} +``` + +v2: +```go +func (m model) View() tea.View { + v := tea.NewView("内容") + v.AltScreen = true // 进入备用屏幕 + return v +} +``` + +### KeyMsg 变更 + +v1: +```go +case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + } +``` + +v2: +```go +case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "space": // 注意:空格键改为"space" + } +``` + +### 快捷键对比 + +| 功能 | v1 | v2 | +|-----|-----|-----| +| Ctrl+C | `tea.KeyCtrlC` | `"ctrl+c"` | +| Alt修饰键 | `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` | +| 空格键 | `" "` | `"space"` | + +### viewport API变更 + +| 功能 | v1 | v2 | +|-----|-----|-----| +| 滚动上 | `m.viewport.LineUp(n)` | `m.viewport.ScrollUp(n)` | +| 滚动下 | `m.viewport.LineDown(n)` | `m.viewport.ScrollDown(n)` | +| 宽度 | `m.viewport.Width` | `m.viewport.Width()` | +| 高度 | `m.viewport.Height` | `m.viewport.Height()` | +| 创建 | `viewport.New(w, h)` | `viewport.New(viewport.WithWidth(w), viewport.WithHeight(h))` | + +### textarea API变更 + +| 功能 | v1 | v2 | +|-----|-----|-----| +| 默认样式 | `textarea.DefaultStyles()` | `textarea.DefaultStyles(isDark bool)` | +| 重置内容 | `m.input.SetValue("")` | `m.input.Reset()` | + +### Program启动 + +v1: +```go +p := tea.NewProgram(model{}) +p.Start() +``` + +v2: +```go +p := tea.NewProgram(model{}) +p.Run() ``` \ No newline at end of file diff --git a/taolun.md b/taolun.md index 807aab8..aefe430 100644 --- a/taolun.md +++ b/taolun.md @@ -649,4 +649,113 @@ func (m model) doTranslate(text, toLang string) tea.Cmd { **下一步**: 实现模块8: 弹出框组件 **关联文档**: -- [changelog.md#0.7.0](changelog.md#070) \ No newline at end of file +- [changelog.md#0.7.0](changelog.md#070) + +--- + +### [2026-04-06 15:00] 版本 0.8.0 - TUI重构: 聊天风格界面 +**原因**: 用户希望使用类似 charmbracelet/crush 的聊天风格界面 +**分析**: +- 当前界面:标题在上 → 输入框在中 → 结果在下 +- 期望界面:标题在上 → 聊天消息区域(可滚动历史)→ 输入框固定底部 → 状态栏最底部 +- 原文+译文成对显示,类似聊天软件 + +**解决方案**: +1. 界面布局重构: + ``` + ┌─────────────────────────────────────────┐ + │ ✦ YOYO 翻译 [Ctrl+C退出] │ + ├─────────────────────────────────────────┤ + │ (聊天消息区域,可滚动查看历史) │ + │ ── 用户输入 ── │ + │ Hello world │ + │ ── 翻译结果 ── │ + │ 你好世界 │ + │ ... │ + ├─────────────────────────────────────────┤ + │ 输入框... [回车] │ + ├─────────────────────────────────────────┤ + │ 目标:zh-CN │ 模型:gpt-3.5 │ Tokens:125 │ + └─────────────────────────────────────────┘ + ``` + +2. 技术方案: + - 消息结构:原文+译文成对显示 + - 底部固定输入框(textarea) + - 状态栏显示完整信息 + - Ctrl+J 换行,Enter 发送 + - 自动调整输入框高度 + +**下一步**: 模块1: 创建TUI模块结构 + +**关联文档**: +- [changelog.md#0.8.0](changelog.md#080) + +--- + +### [2026-04-06 16:00] 版本 0.8.0 - 输入框踩坑与修复 +**原因**: Ctrl+J换行后第一行被遮住,显示错乱 +**分析**: +- 换行后 textarea 内部渲染3行但只显示2行 +- 第一行内容往上滚动被遮住,光标在第2行,第3行是空行 +- 尝试多种方案均无效:移除Width限制、设置SetWidth/SetHeight顺序、动态调整高度 + +**解决方案**: +1. 放弃动态调整高度方案,固定高度为5行 +2. 超过5行时textarea内部自动滚动,光标始终可见 + +**技术细节**: +- textarea内部使用viewport组件,频繁SetHeight导致滚动位置错乱 +- 使用 FocusedStyle/BlurredStyle + Style.Base 设置背景色 + +**代码变更**: +```go +ta.SetWidth(60) +ta.SetHeight(5) // 固定高度,不动态调整 +``` + +**下一步**: 完善其他UI功能 + +**关联文档**: +- [memory.md#TUI输入框踩坑记录](memory.md#tui输入框踩坑记录) +- [changelog.md#0.8.0](changelog.md#080) + +--- + +### [2026-04-07 10:00] 版本 0.8.1 - 翻译结果卡片组件设计 +**原因**: 用户希望改进翻译结果显示样式,使用lipgloss构建更美观的组件 +**分析**: +- 当前翻译结果显示比较简单,只有标签和内容 +- 需要设计一个结构化的翻译卡片组件 +- 组件需要显示Tokens、翻译时间、模型名称等元信息 + +**解决方案**: +1. **卡片组件结构**: + ``` + ┌─ 标题栏 ─────────────────────────────────────────┐ + │ Tokens: 150 │ 耗时: 1.2s │ 模型: gpt-4 │ + └─────────────────────────────────────────────────┘ + ┌─ 用户输入 ───────────────────────────────────────┐ + │ ██████████ 碳黑背景 ████████████████████████████ │ + └─────────────────────────────────────────────────┘ + ┌─ 翻译结果 ───────────────────────────────────────┐ + │ AI 翻译的文本内容... │ + └─────────────────────────────────────────────────┘ + ``` + +2. **技术实现**: + - 使用 lipgloss.Div() 和 lipgloss.JoinVertical() 构建组件 + - 背景色: #1A1A1A (碳黑色) + - 用户输入区域: 纯背景色,无边框 + - 组件间距: 5px marginBottom + +3. **样式定义**: + - CardStyle: 卡片容器,marginBottom(5) + - CardMetaStyle: 元信息行样式,#6B7280 灰色 + - CardInputStyle: 用户输入,#1A1A1A 背景 + #E5E7EB 文字 + - CardOutputStyle: 翻译结果,白色文字 + +**下一步**: 实现组件代码 + +**关联文档**: +- [changelog.md#0.8.1](changelog.md#081) \ No newline at end of file