package inbox import ( "fmt" "io" "strings" "time" "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 FromName string Subject string Date time.Time Preview string Account string AccountID string } func (e EmailItem) FilterValue() string { return fmt.Sprintf("%s %s %s %s", e.Account, e.FromName, e.From, e.Subject) } func (e EmailItem) Title() string { return fmt.Sprintf("%s · %s - %s", e.Account, e.FromName, e.Subject) } func (e EmailItem) Description() string { return e.Preview } func formatTimeAgo(d time.Duration) string { if d < time.Minute { return "刚刚" } if d < time.Hour { mins := int(d.Minutes()) return fmt.Sprintf("%d分钟前", mins) } if d < 24*time.Hour { hours := int(d.Hours()) return fmt.Sprintf("%d小时前", hours) } if d < 7*24*time.Hour { days := int(d.Hours() / 24) return fmt.Sprintf("%d天前", days) } 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 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{}, 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{}, windowWidth: 0, spinner: s, } } func (m *InboxModel) SetEmails(emails []EmailItem) { m.emails = emails items := make([]list.Item, len(emails)) for i, e := range emails { items[i] = e } m.list.SetItems(items) m.list.Title = fmt.Sprintf("收件箱 (%d 封新邮件)", len(items)) } func (m InboxModel) Init() tea.Cmd { 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.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 } m.list, cmd = m.list.Update(msg) return m, cmd } 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 tea.NewView(loadingStyle.Render("正在加载邮件...")) } if m.err != nil { return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err))) } 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) { m.loading = loading } func (m *InboxModel) SetError(err error) { m.err = err } func (m InboxModel) SelectedEmail() *EmailItem { idx := m.list.Index() if idx >= 0 && idx < len(m.emails) { return &m.emails[idx] } return nil } var ( inboxTitleStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("219")). Bold(true) inboxNoItemsStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Padding(1, 2) loadingStyle = lipgloss.NewStyle(). 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 2 } func (d emailDelegate) Spacing() int { return 0 } func (d emailDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { email, ok := item.(EmailItem) if !ok { return } isSelected := index == m.Index() 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 { 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)) } }