feat: 收件箱功能新增按回车查看详情面板
- 添加邮件详情面板显示(主题、发件人、收件人、抄送、账户、时间、正文) - 优化邮件列表卡片样式,增加选中高亮效果 - 窗口宽度 >= 80 时启用双面板布局,左侧列表右侧详情 - 简化依赖包,从 charm.land 使用统一导入路径 - 删除未使用的 golangci/goreleaser 配置文件
This commit is contained in:
378
inbox/model.go
378
inbox/model.go
@@ -3,13 +3,37 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
_ "charm.land/bubbles/v2/help"
|
||||
)
|
||||
|
||||
type EmailDetail struct {
|
||||
UID uint32
|
||||
From string
|
||||
FromName string
|
||||
To string
|
||||
Cc string
|
||||
Subject string
|
||||
Date time.Time
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
Account string
|
||||
AccountID string
|
||||
}
|
||||
|
||||
type DetailResultMsg struct {
|
||||
Detail *EmailDetail
|
||||
Err error
|
||||
}
|
||||
|
||||
type EmailItem struct {
|
||||
UID uint32
|
||||
From string
|
||||
@@ -52,25 +76,77 @@ func formatTimeAgo(d time.Duration) string {
|
||||
return d.Truncate(24 * time.Hour).String()
|
||||
}
|
||||
|
||||
func truncateString(s string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxWidth {
|
||||
return s
|
||||
}
|
||||
if maxWidth <= 3 {
|
||||
return strings.Repeat(".", maxWidth)
|
||||
}
|
||||
return string(runes[:maxWidth-3]) + "..."
|
||||
}
|
||||
|
||||
type InboxModel struct {
|
||||
list list.Model
|
||||
emails []EmailItem
|
||||
loading bool
|
||||
err error
|
||||
selectedEmail *EmailItem
|
||||
list list.Model
|
||||
emails []EmailItem
|
||||
loading bool
|
||||
err error
|
||||
selectedEmail *EmailItem
|
||||
selectedDetail *EmailDetail
|
||||
loadingDetail bool
|
||||
windowWidth int
|
||||
windowHeight int
|
||||
helpKeyMap inboxHelpKeyMap
|
||||
spinner spinner.Model
|
||||
detailFetcher DetailFetcher
|
||||
}
|
||||
|
||||
type inboxHelpKeyMap struct{}
|
||||
|
||||
func (i inboxHelpKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(key.WithHelp("↑", "上移")),
|
||||
key.NewBinding(key.WithHelp("↓", "下移")),
|
||||
key.NewBinding(key.WithHelp("enter", "查看详情")),
|
||||
key.NewBinding(key.WithHelp("/", "搜索")),
|
||||
key.NewBinding(key.WithHelp("q", "退出")),
|
||||
}
|
||||
}
|
||||
|
||||
func (i inboxHelpKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{
|
||||
key.NewBinding(key.WithHelp("↑↓", "移动")),
|
||||
key.NewBinding(key.WithHelp("enter", "查看详情")),
|
||||
key.NewBinding(key.WithHelp("/", "搜索")),
|
||||
key.NewBinding(key.WithHelp("q", "退出")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewInboxModel() *InboxModel {
|
||||
l := list.New(nil, emailDelegate{}, 0, 10)
|
||||
l := list.New(nil, emailDelegate{}, 40, 14)
|
||||
l.Title = "收件箱"
|
||||
l.Styles = list.DefaultStyles(true)
|
||||
l.Styles.Title = inboxTitleStyle
|
||||
l.Styles.NoItems = inboxNoItemsStyle
|
||||
l.SetShowHelp(true)
|
||||
l.SetShowPagination(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = spinnerStyle
|
||||
|
||||
return &InboxModel{
|
||||
list: l,
|
||||
emails: []EmailItem{},
|
||||
list: l,
|
||||
emails: []EmailItem{},
|
||||
windowWidth: 0,
|
||||
spinner: s,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,31 +161,176 @@ func (m *InboxModel) SetEmails(emails []EmailItem) {
|
||||
}
|
||||
|
||||
func (m InboxModel) Init() tea.Cmd {
|
||||
return nil
|
||||
return tea.Batch(
|
||||
func() tea.Msg {
|
||||
return tea.WindowSizeMsg{Width: 120, Height: 30}
|
||||
},
|
||||
m.spinner.Tick,
|
||||
)
|
||||
}
|
||||
|
||||
func fetchEmailDetail(accountID string, uid uint32, fetcher DetailFetcher) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
detail, err := fetcher(accountID, uid)
|
||||
return DetailResultMsg{Detail: detail, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *InboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if msg.String() == "enter" {
|
||||
email := m.SelectedEmail()
|
||||
if email != nil && m.detailFetcher != nil {
|
||||
m.loadingDetail = true
|
||||
m.selectedDetail = nil
|
||||
return m, fetchEmailDetail(email.AccountID, email.UID, m.detailFetcher)
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetWidth(msg.Width)
|
||||
m.windowWidth = msg.Width
|
||||
m.windowHeight = msg.Height
|
||||
m.list.SetWidth(calculateListWidth(msg.Width))
|
||||
m.list.SetHeight(int(float64(msg.Height) * 0.8))
|
||||
case DetailResultMsg:
|
||||
m.loadingDetail = false
|
||||
if msg.Err != nil {
|
||||
m.err = msg.Err
|
||||
} else {
|
||||
m.selectedDetail = msg.Detail
|
||||
}
|
||||
case spinner.TickMsg:
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m InboxModel) View() string {
|
||||
func calculateListWidth(totalWidth int) int {
|
||||
if totalWidth <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(float64(totalWidth) * 0.45)
|
||||
}
|
||||
|
||||
func (m InboxModel) View() tea.View {
|
||||
if m.loading {
|
||||
return loadingStyle.Render("正在加载邮件...")
|
||||
return tea.NewView(loadingStyle.Render("正在加载邮件..."))
|
||||
}
|
||||
if m.err != nil {
|
||||
return errorStyle.Render(fmt.Sprintf("错误: %v", m.err))
|
||||
return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err)))
|
||||
}
|
||||
return m.list.View()
|
||||
|
||||
listWidth := calculateListWidth(m.windowWidth)
|
||||
detailWidth := m.windowWidth - listWidth - 1
|
||||
|
||||
m.list.SetWidth(listWidth)
|
||||
|
||||
mainContentStyle := lipgloss.NewStyle().
|
||||
Width(m.windowWidth - 1).
|
||||
Height(m.windowHeight - 2)
|
||||
|
||||
helpBarStyle := lipgloss.NewStyle().
|
||||
Width(m.windowWidth - 1).
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
helpView := helpBarStyle.Render(m.list.Help.View(m.helpKeyMap))
|
||||
|
||||
if m.windowWidth >= 80 {
|
||||
detailView := m.renderDetailPanel(detailWidth)
|
||||
listView := m.list.View()
|
||||
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, listView, detailView)
|
||||
box := mainContentStyle.Render(mainContent)
|
||||
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
listView := m.list.View()
|
||||
box := mainContentStyle.Render(listView)
|
||||
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m InboxModel) renderHelpBar() string {
|
||||
helpItems := "↑↓ 移动 │ enter 查看详情 │ / 搜索 │ q 退出"
|
||||
return helpBarStyle.Render(helpItems)
|
||||
}
|
||||
|
||||
func (m InboxModel) renderDetailPanel(listWidth int) string {
|
||||
email := m.SelectedEmail()
|
||||
if email == nil {
|
||||
return detailPanelStyle.Render("选择一封邮件查看详情")
|
||||
}
|
||||
|
||||
if m.loadingDetail {
|
||||
spinnerView := m.spinner.View()
|
||||
loadingText := detailMetaStyle.Render(spinnerView + " 正在加载邮件内容...")
|
||||
return detailPanelStyle.Width(40).Render(loadingText)
|
||||
}
|
||||
|
||||
if m.selectedDetail != nil {
|
||||
detail := m.selectedDetail
|
||||
subject := detailTitleStyle.Render(detail.Subject)
|
||||
|
||||
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s", detail.From))
|
||||
|
||||
to := detailMetaStyle.Render(fmt.Sprintf("收件人: %s", detail.To))
|
||||
if detail.Cc != "" {
|
||||
to += "\n" + detailMetaStyle.Render(fmt.Sprintf("抄送: %s", detail.Cc))
|
||||
}
|
||||
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", detail.Account))
|
||||
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", detail.Date.Format("2006-01-02 15:04")))
|
||||
|
||||
var body string
|
||||
if detail.TextBody != "" {
|
||||
body = detailBodyStyle.Render(detail.TextBody)
|
||||
} else if detail.HTMLBody != "" {
|
||||
body = detailMetaStyle.Render("[HTML邮件内容]")
|
||||
} else {
|
||||
body = detailMetaStyle.Render("[无正文]")
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", subject, from, to, account, date, body)
|
||||
|
||||
detailWidth := 40
|
||||
if m.windowWidth > 0 {
|
||||
detailWidth = m.windowWidth - listWidth - 1
|
||||
}
|
||||
if detailWidth < 10 {
|
||||
detailWidth = 40
|
||||
}
|
||||
|
||||
return detailPanelStyle.Width(detailWidth).Render(content)
|
||||
}
|
||||
|
||||
subject := detailTitleStyle.Render(email.Subject)
|
||||
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s <%s>", email.FromName, email.From))
|
||||
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", email.Account))
|
||||
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", email.Date.Format("2006-01-02 15:04")))
|
||||
preview := detailPanelStyle.Render(email.Preview)
|
||||
|
||||
content := fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", subject, from, account, date, preview)
|
||||
|
||||
detailWidth := 40
|
||||
if m.windowWidth > 0 {
|
||||
detailWidth = m.windowWidth - listWidth - 1
|
||||
}
|
||||
if detailWidth < 10 {
|
||||
detailWidth = 40
|
||||
}
|
||||
|
||||
return detailPanelStyle.Width(detailWidth).Render(content)
|
||||
}
|
||||
|
||||
func (m *InboxModel) SetLoading(loading bool) {
|
||||
@@ -130,7 +351,7 @@ func (m InboxModel) SelectedEmail() *EmailItem {
|
||||
|
||||
var (
|
||||
inboxTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Foreground(lipgloss.Color("219")).
|
||||
Bold(true)
|
||||
|
||||
inboxNoItemsStyle = lipgloss.NewStyle().
|
||||
@@ -138,17 +359,75 @@ var (
|
||||
Padding(1, 2)
|
||||
|
||||
loadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Foreground(lipgloss.Color("219")).
|
||||
Padding(1, 2)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Padding(1, 2)
|
||||
|
||||
emailCardSubjectStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Bold(true).
|
||||
Padding(0, 2)
|
||||
|
||||
emailCardMetaStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Padding(0, 2)
|
||||
|
||||
emailCardSubjectSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Bold(true).
|
||||
Background(lipgloss.Color("99")).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(lipgloss.Color("219")).
|
||||
Padding(0, 2)
|
||||
|
||||
emailCardMetaSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("186")).
|
||||
Background(lipgloss.Color("99")).
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(lipgloss.Color("219")).
|
||||
Padding(0, 2)
|
||||
|
||||
detailPanelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Padding(1, 2)
|
||||
|
||||
detailTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("219")).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
detailMetaStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Padding(0, 1)
|
||||
|
||||
detailBodyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Padding(0, 1)
|
||||
|
||||
helpBarStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Padding(0, 1)
|
||||
|
||||
spinnerStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("219"))
|
||||
|
||||
cardSpacing = 1
|
||||
)
|
||||
|
||||
type DetailFetcher func(accountID string, uid uint32) (*EmailDetail, error)
|
||||
|
||||
func (m *InboxModel) SetDetailFetcher(fetcher DetailFetcher) {
|
||||
m.detailFetcher = fetcher
|
||||
}
|
||||
|
||||
type emailDelegate struct{}
|
||||
|
||||
func (d emailDelegate) Height() int { return 1 }
|
||||
func (d emailDelegate) Height() int { return 2 }
|
||||
func (d emailDelegate) Spacing() int { return 0 }
|
||||
func (d emailDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
|
||||
return nil
|
||||
@@ -161,29 +440,42 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It
|
||||
|
||||
isSelected := index == m.Index()
|
||||
|
||||
titleStyle := emailTitleStyle
|
||||
descStyle := emailDescStyle
|
||||
width := m.Width() - 1
|
||||
subjectWidth := width - 2
|
||||
metaWidth := width - 2
|
||||
|
||||
subject := truncateString(email.Subject, subjectWidth)
|
||||
meta := truncateString("▣ "+email.Account+" · "+formatTimeAgo(time.Since(email.Date)), metaWidth)
|
||||
|
||||
if isSelected {
|
||||
titleStyle = emailTitleStyleSelected
|
||||
descStyle = emailDescStyleSelected
|
||||
subjectStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Background(lipgloss.Color("99")).
|
||||
Padding(1, 1, 0, 1)
|
||||
|
||||
metaStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color("186")).
|
||||
Background(lipgloss.Color("99")).
|
||||
Padding(0, 1, 1, 1)
|
||||
|
||||
fmt.Fprintf(w, "%s\n%s",
|
||||
subjectStyle.Render(subject),
|
||||
metaStyle.Render(meta))
|
||||
} else {
|
||||
subjectStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Padding(1, 1, 0, 1)
|
||||
|
||||
metaStyle := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color("241")).
|
||||
Padding(0, 1, 1, 1)
|
||||
|
||||
fmt.Fprintf(w, "%s\n%s",
|
||||
subjectStyle.Render(subject),
|
||||
metaStyle.Render(meta))
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\n%s", titleStyle.Render(email.Title()), descStyle.Render(email.Description()))
|
||||
}
|
||||
|
||||
var (
|
||||
emailTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255"))
|
||||
|
||||
emailTitleStyleSelected = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("255")).
|
||||
Background(lipgloss.Color("68"))
|
||||
|
||||
emailDescStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241"))
|
||||
|
||||
emailDescStyleSelected = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("186")).
|
||||
Background(lipgloss.Color("68"))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user