composer.go

   1package tui
   2
   3import (
   4	"fmt"
   5	"net/mail"
   6	"os"
   7	"path/filepath"
   8	"strings"
   9	"time"
  10	"unicode"
  11
  12	"charm.land/bubbles/v2/textarea"
  13	"charm.land/bubbles/v2/textinput"
  14	tea "charm.land/bubbletea/v2"
  15	"charm.land/lipgloss/v2"
  16	overlay "github.com/floatpane/bubble-overlay"
  17	"github.com/floatpane/matcha/config"
  18	"github.com/floatpane/matcha/spellcheck"
  19	"github.com/google/uuid"
  20)
  21
  22// spellcheckReadyMsg is delivered when the background spellcheck loader
  23// finishes (either downloading the default dictionary or loading an
  24// already-installed one).
  25type spellcheckReadyMsg struct {
  26	checker *spellcheck.Checker
  27}
  28
  29var (
  30	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  31	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  32	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("245")).Padding(0, 1)
  33)
  34
  35// Styles for the UI
  36var (
  37	focusedStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
  38	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  39	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
  40	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  41	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  42	smimeToggleStyle    = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("245"))
  43	composerErrorStyle  = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("196"))
  44)
  45
  46const (
  47	focusFrom = iota
  48	focusTo
  49	focusCc
  50	focusBcc
  51	focusSubject
  52	focusBody
  53	focusSignature
  54	focusAttachment
  55	focusEncryptSMIME
  56	focusSend
  57)
  58
  59type hideComposerNoticeMsg struct{}
  60
  61// Composer model holds the state of the email composition UI.
  62type Composer struct {
  63	focusIndex       int
  64	toInput          textinput.Model
  65	ccInput          textinput.Model
  66	bccInput         textinput.Model
  67	fromError        string
  68	toError          string
  69	ccError          string
  70	bccError         string
  71	subjectInput     textinput.Model
  72	bodyInput        textarea.Model
  73	signatureInput   textarea.Model
  74	attachmentPaths  []string
  75	attachmentNames  map[string]string
  76	attachmentCursor int
  77	encryptSMIME     bool
  78	width            int
  79	height           int
  80	confirmingExit   bool
  81	showNotice       bool
  82	noticeText       string
  83	hideTips         bool
  84
  85	// Multi-account support
  86	accounts           []config.Account
  87	selectedAccountIdx int
  88	showAccountPicker  bool
  89	fromInput          textinput.Model // editable From when account is catch-all
  90
  91	// Contact suggestions
  92	suggestions        []config.Contact
  93	selectedSuggestion int
  94	showSuggestions    bool
  95	lastToValue        string
  96
  97	// Draft persistence
  98	draftID string
  99
 100	// Reply context
 101	inReplyTo  string
 102	references []string
 103
 104	// Hidden quoted text (appended to body when sending, but not shown in editor)
 105	quotedText string
 106
 107	// Plugin status text shown in the help bar
 108	pluginStatus      string
 109	pluginKeyBindings []PluginKeyBinding
 110
 111	// Plugin prompt overlay
 112	showPluginPrompt        bool
 113	pluginPromptInput       textinput.Model
 114	pluginPromptPlaceholder string
 115
 116	// Spellcheck (loaded asynchronously; nil until ready).
 117	spellChecker            *spellcheck.Checker
 118	spellSuggestions        []string
 119	spellSelected           int
 120	spellShow               bool
 121	spellWordOnLine         int    // index of the logical line containing the word
 122	spellWordLineStart      int    // byte offset of the word within its logical line
 123	spellWordLineEnd        int    // byte offset of the word's end within its logical line
 124	spellWord               string // the misspelled word (as currently in body)
 125	spellLastBody           string // last body value we computed suggestions for
 126	spellLastCursorRow      int
 127	spellLastCursorCol      int
 128	disableSpellcheck       bool
 129	disableSpellSuggestions bool
 130}
 131
 132// NewComposer initializes a new composer model.
 133func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
 134	m := &Composer{
 135		draftID:         uuid.New().String(),
 136		hideTips:        hideTips,
 137		attachmentNames: make(map[string]string),
 138	}
 139
 140	tiStyles := ThemedTextInputStyles()
 141	taStyles := ThemedTextAreaStyles()
 142
 143	m.toInput = textinput.New()
 144	m.toInput.Placeholder = t("composer.to_placeholder")
 145	m.toInput.SetValue(to)
 146	m.toInput.Prompt = "> "
 147	m.toInput.CharLimit = 256
 148	m.toInput.SetStyles(tiStyles)
 149
 150	m.ccInput = textinput.New()
 151	m.ccInput.Placeholder = t("composer.cc_placeholder")
 152	m.ccInput.Prompt = "> "
 153	m.ccInput.CharLimit = 256
 154	m.ccInput.SetStyles(tiStyles)
 155
 156	m.bccInput = textinput.New()
 157	m.bccInput.Placeholder = t("composer.bcc_placeholder")
 158	m.bccInput.Prompt = "> "
 159	m.bccInput.CharLimit = 256
 160	m.bccInput.SetStyles(tiStyles)
 161
 162	m.subjectInput = textinput.New()
 163	m.subjectInput.Placeholder = t("composer.subject_placeholder")
 164	m.subjectInput.SetValue(subject)
 165	m.subjectInput.Prompt = "> "
 166	m.subjectInput.CharLimit = 256
 167	m.subjectInput.SetStyles(tiStyles)
 168
 169	m.bodyInput = textarea.New()
 170	m.bodyInput.Placeholder = t("composer.body_placeholder")
 171	m.bodyInput.SetValue(body)
 172	m.bodyInput.Prompt = "> "
 173	m.bodyInput.SetHeight(10)
 174	m.bodyInput.SetStyles(taStyles)
 175
 176	m.signatureInput = textarea.New()
 177	m.signatureInput.Placeholder = t("composer.signature_placeholder")
 178	m.signatureInput.Prompt = "> "
 179	m.signatureInput.SetHeight(3)
 180	m.signatureInput.SetStyles(taStyles)
 181	m.updateSignature()
 182
 183	m.fromInput = textinput.New()
 184	m.fromInput.Placeholder = t("composer.from_placeholder")
 185	m.fromInput.Prompt = "> "
 186	m.fromInput.CharLimit = 256
 187	m.fromInput.SetStyles(tiStyles)
 188
 189	// Start focus on To field (From is selectable but not a text input)
 190	m.focusIndex = focusTo
 191	m.toInput.Focus()
 192
 193	return m
 194}
 195
 196func normalizeEmailList(value string) (string, bool) {
 197	value = strings.TrimSpace(value)
 198	if value == "" {
 199		return "", true
 200	}
 201
 202	parts := strings.Split(value, ",")
 203	addresses := make([]string, 0, len(parts))
 204	for _, part := range parts {
 205		part = strings.TrimSpace(part)
 206		if part == "" {
 207			continue
 208		}
 209		addr, err := mail.ParseAddress(part)
 210		if err != nil || addr.Address == "" {
 211			return value, false
 212		}
 213		addresses = append(addresses, addr.Address)
 214	}
 215	if len(addresses) == 0 {
 216		return "", true
 217	}
 218	return strings.Join(addresses, ", "), true
 219}
 220
 221func (m *Composer) hasAnyRecipient() bool {
 222	return strings.TrimSpace(m.toInput.Value()) != "" ||
 223		strings.TrimSpace(m.ccInput.Value()) != "" ||
 224		strings.TrimSpace(m.bccInput.Value()) != ""
 225}
 226
 227func (m *Composer) showComposerNotice(message string) tea.Cmd {
 228	m.noticeText = message
 229	m.showNotice = true
 230	return tea.Tick(5*time.Second, func(time.Time) tea.Msg {
 231		return hideComposerNoticeMsg{}
 232	})
 233}
 234
 235func (m *Composer) hideComposerNotice() {
 236	m.showNotice = false
 237	m.noticeText = ""
 238}
 239
 240func (m *Composer) validateFromField() bool { //nolint:unparam
 241	if !m.isCatchAllAccount() {
 242		m.fromError = ""
 243		return true
 244	}
 245	value := strings.TrimSpace(m.fromInput.Value())
 246	addr, err := mail.ParseAddress(value)
 247	if value == "" || err != nil || addr.Address == "" {
 248		m.fromError = t("composer.invalid_email")
 249		return false
 250	}
 251	m.fromError = ""
 252	return true
 253}
 254
 255func (m *Composer) validateEmailField(focus int) bool { //nolint:unparam
 256	var input *textinput.Model
 257	var setError func(string)
 258	switch focus {
 259	case focusTo:
 260		input = &m.toInput
 261		setError = func(err string) { m.toError = err }
 262	case focusCc:
 263		input = &m.ccInput
 264		setError = func(err string) { m.ccError = err }
 265	case focusBcc:
 266		input = &m.bccInput
 267		setError = func(err string) { m.bccError = err }
 268	default:
 269		return true
 270	}
 271
 272	normalized, ok := normalizeEmailList(input.Value())
 273	if !ok {
 274		setError(t("composer.invalid_email"))
 275		return false
 276	}
 277	input.SetValue(normalized)
 278	setError("")
 279	return true
 280}
 281
 282func (m *Composer) canSendEmail() bool {
 283	m.validateFromField()
 284	m.validateEmailField(focusTo)
 285	m.validateEmailField(focusCc)
 286	m.validateEmailField(focusBcc)
 287	return m.fromError == "" && m.toError == "" && m.ccError == "" && m.bccError == ""
 288}
 289
 290// updateSignature updates the signature input based on the current selected account.
 291func (m *Composer) updateSignature() {
 292	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 293		acc := &m.accounts[m.selectedAccountIdx]
 294		if sig, err := config.LoadSignatureForAccount(acc); err == nil && sig != "" {
 295			m.signatureInput.SetValue(sig)
 296		} else if sig, err := config.LoadSignature(); err == nil && sig != "" {
 297			m.signatureInput.SetValue(sig)
 298		} else {
 299			m.signatureInput.SetValue("")
 300		}
 301		// Seed the editable From address for catch-all accounts.
 302		m.fromInput.SetValue(acc.FormatFromHeader())
 303		return
 304	}
 305
 306	if sig, err := config.LoadSignature(); err == nil && sig != "" {
 307		m.signatureInput.SetValue(sig)
 308	} else {
 309		m.signatureInput.SetValue("")
 310	}
 311}
 312
 313// NewComposerWithAccounts initializes a composer with multiple account support.
 314func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
 315	m := NewComposer("", to, subject, body, hideTips)
 316	m.accounts = accounts
 317
 318	// Find the selected account index
 319	for i, acc := range accounts {
 320		if acc.ID == selectedAccountID {
 321			m.selectedAccountIdx = i
 322			break
 323		}
 324	}
 325	m.updateSignature()
 326
 327	return m
 328}
 329
 330// ResetConfirmation ensures a restored draft isnt stuck in the exit prompt.
 331func (m *Composer) ResetConfirmation() {
 332	m.confirmingExit = false
 333}
 334
 335// SetFromOverride pre-fills the editable From field (used for catch-all replies).
 336func (m *Composer) SetFromOverride(addr string) {
 337	m.fromInput.SetValue(addr)
 338}
 339
 340// SetSpellcheckOptions toggles spellcheck features for this composer. Pass
 341// disableCheck=true to skip dictionary download/highlighting entirely;
 342// disableSuggestions=true keeps inline underlines but suppresses the popup.
 343func (m *Composer) SetSpellcheckOptions(disableCheck, disableSuggestions bool) {
 344	m.disableSpellcheck = disableCheck
 345	m.disableSpellSuggestions = disableSuggestions
 346	if disableCheck {
 347		m.spellChecker = nil
 348		m.spellShow = false
 349		m.spellSuggestions = nil
 350	}
 351}
 352
 353func (m *Composer) Init() tea.Cmd {
 354	cmds := []tea.Cmd{textinput.Blink}
 355	if !m.disableSpellcheck {
 356		cmds = append(cmds, loadSpellcheckCmd())
 357	}
 358	return tea.Batch(cmds...)
 359}
 360
 361// loadSpellcheckCmd ensures the default dictionary is downloaded and
 362// loaded into a new Checker. Network errors are swallowed: spellcheck is a
 363// non-essential overlay, so the composer continues to work normally.
 364func loadSpellcheckCmd() tea.Cmd {
 365	return func() tea.Msg {
 366		lang, err := spellcheck.EnsureDefault()
 367		if err != nil {
 368			return spellcheckReadyMsg{checker: nil}
 369		}
 370		c := spellcheck.NewChecker()
 371		if err := c.LoadLang(lang); err != nil {
 372			return spellcheckReadyMsg{checker: nil}
 373		}
 374		return spellcheckReadyMsg{checker: c}
 375	}
 376}
 377
 378// updateSpellSuggestions inspects the body cursor position and refreshes
 379// the suggestion popup. It only fires when the cursor sits at the end of
 380// a misspelled word.
 381func (m *Composer) updateSpellSuggestions() {
 382	m.spellShow = false
 383	m.spellSuggestions = nil
 384	m.spellWord = ""
 385
 386	if m.disableSpellcheck || m.disableSpellSuggestions {
 387		return
 388	}
 389	if m.spellChecker == nil || !m.spellChecker.Loaded() {
 390		return
 391	}
 392	if m.focusIndex != focusBody {
 393		return
 394	}
 395
 396	value := m.bodyInput.Value()
 397	row := m.bodyInput.Line()
 398	col := m.bodyInput.Column()
 399	lines := strings.Split(value, "\n")
 400	if row < 0 || row >= len(lines) {
 401		return
 402	}
 403	line := lines[row]
 404	lineRunes := []rune(line)
 405	if col > len(lineRunes) {
 406		col = len(lineRunes)
 407	}
 408
 409	// Walk back from cursor while we have letters or internal connectors.
 410	end := col
 411	start := col
 412	for start > 0 {
 413		r := lineRunes[start-1]
 414		if isWordContinuation(r) {
 415			start--
 416			continue
 417		}
 418		break
 419	}
 420	// Trim leading connectors so the word starts on a letter.
 421	for start < end && !isLetter(lineRunes[start]) {
 422		start++
 423	}
 424	// Trim trailing connectors so we don't suggest replacements while the
 425	// user is still mid-apostrophe.
 426	for end > start && !isLetter(lineRunes[end-1]) {
 427		end--
 428	}
 429	if end-start < 2 {
 430		return
 431	}
 432
 433	word := string(lineRunes[start:end])
 434	if !spellcheck.IsCheckable(word) {
 435		return
 436	}
 437	if m.spellChecker.Check(word) {
 438		return
 439	}
 440
 441	suggestions := m.spellChecker.Suggest(word, 5)
 442	if len(suggestions) == 0 {
 443		return
 444	}
 445
 446	m.spellSuggestions = suggestions
 447	m.spellSelected = 0
 448	m.spellShow = true
 449	m.spellWord = word
 450
 451	// Byte offsets within the current line, needed by the accept handler.
 452	m.spellWordLineStart = len(string(lineRunes[:start]))
 453	m.spellWordLineEnd = len(string(lineRunes[:end]))
 454	m.spellWordOnLine = row
 455
 456	// Cache cursor position so a no-op key (e.g. arrow without movement)
 457	// doesn't redundantly recompute suggestions.
 458	m.spellLastBody = value
 459	m.spellLastCursorRow = row
 460	m.spellLastCursorCol = col
 461}
 462
 463// acceptSpellSuggestion replaces the misspelled word currently under the
 464// cursor with the selected suggestion. It works by sending backspace key
 465// events to the textarea (so the textarea's own bookkeeping stays in
 466// sync) and then inserting the replacement text.
 467func (m *Composer) acceptSpellSuggestion() {
 468	if !m.spellShow || len(m.spellSuggestions) == 0 {
 469		return
 470	}
 471	if m.spellSelected < 0 || m.spellSelected >= len(m.spellSuggestions) {
 472		return
 473	}
 474	suggestion := m.spellSuggestions[m.spellSelected]
 475
 476	// Only replace when the cursor is still at the end of the word we
 477	// recorded — otherwise the user moved and the popup is stale.
 478	row := m.bodyInput.Line()
 479	col := m.bodyInput.Column()
 480	lines := strings.Split(m.bodyInput.Value(), "\n")
 481	if row != m.spellWordOnLine || row >= len(lines) {
 482		m.spellShow = false
 483		m.spellSuggestions = nil
 484		return
 485	}
 486	endRunes := len([]rune(lines[row][:m.spellWordLineEnd]))
 487	if col != endRunes {
 488		m.spellShow = false
 489		m.spellSuggestions = nil
 490		return
 491	}
 492
 493	wordRuneLen := len([]rune(m.spellWord))
 494	for i := 0; i < wordRuneLen; i++ {
 495		m.bodyInput, _ = m.bodyInput.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
 496	}
 497	m.bodyInput.InsertString(suggestion)
 498
 499	m.spellShow = false
 500	m.spellSuggestions = nil
 501	m.spellWord = ""
 502}
 503
 504func isWordContinuation(r rune) bool {
 505	return isLetter(r) || r == '\'' || r == '’' || r == '-'
 506}
 507
 508func isLetter(r rune) bool {
 509	if r < 0x80 {
 510		return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
 511	}
 512	return unicode.IsLetter(r)
 513}
 514
 515func (m *Composer) getFromAddress() string {
 516	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 517		return m.accounts[m.selectedAccountIdx].FormatFromHeader()
 518	}
 519	return ""
 520}
 521
 522func (m *Composer) isCatchAllAccount() bool {
 523	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 524		return m.accounts[m.selectedAccountIdx].CatchAll
 525	}
 526	return false
 527}
 528
 529func (m *Composer) getSelectedAccount() *config.Account {
 530	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
 531		return &m.accounts[m.selectedAccountIdx]
 532	}
 533	return nil
 534}
 535
 536func formatAttachmentName(path string) string {
 537	name := filepath.Base(path)
 538	info, err := os.Stat(path)
 539	if err != nil || info.IsDir() {
 540		return name
 541	}
 542	return fmt.Sprintf("%s (%s)", name, tfs(info.Size()))
 543}
 544
 545func (m *Composer) attachmentDisplayName(path string) string {
 546	if name, ok := m.attachmentNames[path]; ok {
 547		return name
 548	}
 549	return filepath.Base(path)
 550}
 551
 552func (m *Composer) clampAttachmentCursor() {
 553	if len(m.attachmentPaths) == 0 {
 554		m.attachmentCursor = 0
 555		return
 556	}
 557	if m.attachmentCursor < 0 {
 558		m.attachmentCursor = 0
 559	}
 560	if m.attachmentCursor >= len(m.attachmentPaths) {
 561		m.attachmentCursor = len(m.attachmentPaths) - 1
 562	}
 563}
 564
 565func (m *Composer) removeSelectedAttachment() {
 566	if len(m.attachmentPaths) == 0 {
 567		return
 568	}
 569
 570	m.clampAttachmentCursor()
 571	idx := m.attachmentCursor
 572	delete(m.attachmentNames, m.attachmentPaths[idx])
 573	m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
 574	m.clampAttachmentCursor()
 575}
 576
 577func suggestionDisplay(s config.Contact, suggestionWidth int) string {
 578	display := s.Email
 579	if len(s.Addresses) > 0 {
 580		display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", "))
 581		return truncateSuggestionDisplay(display, suggestionWidth)
 582	} else if s.Name != "" && s.Name != s.Email {
 583		display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
 584	}
 585	return display
 586}
 587
 588func suggestionDisplayWidth(width int) int {
 589	if width > 12 {
 590		return width - 6
 591	}
 592	return 40
 593}
 594
 595func truncateSuggestionDisplay(s string, maxLen int) string {
 596	runes := []rune(s)
 597	if len(runes) <= maxLen {
 598		return s
 599	}
 600	if maxLen <= 0 {
 601		return ""
 602	}
 603	if maxLen <= 3 {
 604		return string(runes[:maxLen])
 605	}
 606	return string(runes[:maxLen-3]) + "..."
 607}
 608
 609func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 610	var cmds []tea.Cmd
 611	var cmd tea.Cmd
 612
 613	switch msg := msg.(type) {
 614	case tea.WindowSizeMsg:
 615		m.width = msg.Width
 616		m.height = msg.Height
 617		inputWidth := msg.Width - 6
 618		m.toInput.SetWidth(inputWidth)
 619		m.ccInput.SetWidth(inputWidth)
 620		m.bccInput.SetWidth(inputWidth)
 621		m.subjectInput.SetWidth(inputWidth)
 622		m.bodyInput.SetWidth(inputWidth)
 623		m.signatureInput.SetWidth(inputWidth)
 624		if msg.Height > 0 {
 625			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
 626			// attachment, smime, button, blank, tip, help = 13
 627			const fixedRows = 13
 628			available := msg.Height - fixedRows
 629			if available < 6 {
 630				available = 6
 631			}
 632			bodyHeight := (available * 55) / 100
 633			sigHeight := (available * 15) / 100
 634			if bodyHeight < 3 {
 635				bodyHeight = 3
 636			}
 637			if sigHeight < 2 {
 638				sigHeight = 2
 639			}
 640			m.bodyInput.SetHeight(bodyHeight)
 641			m.signatureInput.SetHeight(sigHeight)
 642		}
 643
 644	case hideComposerNoticeMsg:
 645		m.hideComposerNotice()
 646		return m, nil
 647
 648	case spellcheckReadyMsg:
 649		if msg.checker != nil {
 650			m.spellChecker = msg.checker
 651			m.updateSpellSuggestions()
 652		}
 653		return m, nil
 654
 655	case FileSelectedMsg:
 656		// Avoid duplicates and add all selected paths
 657		for _, newPath := range msg.Paths {
 658			exists := false
 659			for _, p := range m.attachmentPaths {
 660				if p == newPath {
 661					exists = true
 662					break
 663				}
 664			}
 665			if !exists {
 666				m.attachmentPaths = append(m.attachmentPaths, newPath)
 667				m.attachmentNames[newPath] = formatAttachmentName(newPath)
 668			}
 669		}
 670		m.clampAttachmentCursor()
 671		return m, nil
 672
 673	case tea.KeyPressMsg:
 674		// Handle contact suggestions mode
 675		if m.showSuggestions && len(m.suggestions) > 0 {
 676			switch msg.String() {
 677			case "up", "ctrl+p":
 678				if m.selectedSuggestion > 0 {
 679					m.selectedSuggestion--
 680				}
 681				return m, nil
 682			case keyDown, "ctrl+n":
 683				if m.selectedSuggestion < len(m.suggestions)-1 {
 684					m.selectedSuggestion++
 685				}
 686				return m, nil
 687			case "tab", keyEnter:
 688				// Select the suggestion
 689				selected := m.suggestions[m.selectedSuggestion]
 690
 691				var newEmail string
 692				switch {
 693				case len(selected.Addresses) > 0:
 694					// Mailing list: emit just the addresses to maintain valid email formatting
 695					newEmail = strings.Join(selected.Addresses, ", ")
 696				case selected.Name != "" && selected.Name != selected.Email:
 697					newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
 698				default:
 699					newEmail = selected.Email
 700				}
 701
 702				parts := strings.Split(m.toInput.Value(), ",")
 703				if len(parts) > 0 {
 704					if len(parts) == 1 {
 705						parts[0] = newEmail
 706					} else {
 707						parts[len(parts)-1] = " " + newEmail
 708					}
 709				} else {
 710					parts = []string{newEmail}
 711				}
 712
 713				finalValue := strings.Join(parts, ",")
 714				if !strings.HasSuffix(finalValue, ", ") {
 715					finalValue += ", "
 716				}
 717
 718				m.toInput.SetValue(finalValue)
 719				m.toInput.SetCursor(len(finalValue))
 720				m.toError = ""
 721				m.lastToValue = m.toInput.Value()
 722				m.showSuggestions = false
 723				m.suggestions = nil
 724				return m, nil
 725			case "esc":
 726				m.showSuggestions = false
 727				m.suggestions = nil
 728				return m, nil
 729			}
 730			// For prev-field key, close suggestions and let it fall through to normal handling
 731			if msg.String() == config.Keybinds.Composer.PrevField {
 732				m.showSuggestions = false
 733				m.suggestions = nil
 734			}
 735		}
 736
 737		// Handle plugin prompt overlay
 738		if m.showPluginPrompt {
 739			switch msg.String() {
 740			case keyEnter:
 741				value := m.pluginPromptInput.Value()
 742				m.showPluginPrompt = false
 743				return m, func() tea.Msg { return PluginPromptSubmitMsg{Value: value} }
 744			case "esc":
 745				m.showPluginPrompt = false
 746				return m, func() tea.Msg { return PluginPromptCancelMsg{} }
 747			default:
 748				m.pluginPromptInput, cmd = m.pluginPromptInput.Update(msg)
 749				return m, cmd
 750			}
 751		}
 752
 753		// Handle account picker mode
 754		if m.showAccountPicker {
 755			switch msg.String() {
 756			case "up", "k":
 757				if m.selectedAccountIdx > 0 {
 758					m.selectedAccountIdx--
 759					m.updateSignature()
 760				}
 761			case keyDown, "j":
 762				if m.selectedAccountIdx < len(m.accounts)-1 {
 763					m.selectedAccountIdx++
 764					m.updateSignature()
 765				}
 766			case keyEnter:
 767				m.showAccountPicker = false
 768			case "esc":
 769				m.showAccountPicker = false
 770			}
 771			return m, nil
 772		}
 773
 774		if m.confirmingExit {
 775			switch msg.String() {
 776			case "y", "Y":
 777				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
 778			case "n", "N", "esc":
 779				m.confirmingExit = false
 780				return m, nil
 781			default:
 782				return m, nil
 783			}
 784		}
 785
 786		if m.showNotice {
 787			switch msg.String() {
 788			case keyEnter, "esc", " ":
 789				m.hideComposerNotice()
 790			}
 791			return m, nil
 792		}
 793
 794		// Spellcheck suggestion popup (only while body is focused).
 795		if m.focusIndex == focusBody && m.spellShow && len(m.spellSuggestions) > 0 {
 796			sk := config.Keybinds.Composer
 797			switch msg.String() {
 798			case sk.SpellPrev:
 799				if m.spellSelected > 0 {
 800					m.spellSelected--
 801				}
 802				return m, nil
 803			case sk.SpellNext:
 804				if m.spellSelected < len(m.spellSuggestions)-1 {
 805					m.spellSelected++
 806				}
 807				return m, nil
 808			case sk.SpellAccept:
 809				m.acceptSpellSuggestion()
 810				return m, nil
 811			case sk.SpellDismiss:
 812				m.spellShow = false
 813				m.spellSuggestions = nil
 814				return m, nil
 815			}
 816		}
 817
 818		kb := config.Keybinds
 819		attachmentPathSize := len(m.attachmentPaths)
 820		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
 821			switch msg.String() {
 822			case "up", kb.Global.NavUp:
 823				m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
 824				return m, nil
 825			case keyDown, kb.Global.NavDown:
 826				m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
 827				return m, nil
 828			}
 829		}
 830
 831		switch msg.String() {
 832		case kb.Global.Quit:
 833			return m, tea.Quit
 834		case kb.Composer.ExternalEditor:
 835			return m, func() tea.Msg { return OpenEditorMsg{} }
 836		case kb.Global.Cancel:
 837			m.confirmingExit = true
 838			return m, nil
 839
 840		case kb.Composer.NextField, kb.Composer.PrevField:
 841			previousFocus := m.focusIndex
 842			if msg.String() == kb.Composer.PrevField {
 843				m.focusIndex--
 844			} else {
 845				m.focusIndex++
 846			}
 847
 848			maxFocus := focusSend
 849			minFocus := focusFrom
 850			// Skip From field if only one non-catch-all account (nothing to switch or edit)
 851			if len(m.accounts) <= 1 && !m.isCatchAllAccount() {
 852				minFocus = focusTo
 853			}
 854
 855			if m.focusIndex > maxFocus {
 856				m.focusIndex = minFocus
 857			} else if m.focusIndex < minFocus {
 858				m.focusIndex = maxFocus
 859			}
 860
 861			if previousFocus == focusFrom {
 862				m.validateFromField()
 863			} else if previousFocus != m.focusIndex {
 864				m.validateEmailField(previousFocus)
 865			}
 866
 867			m.fromInput.Blur()
 868			m.toInput.Blur()
 869			m.ccInput.Blur()
 870			m.bccInput.Blur()
 871			m.subjectInput.Blur()
 872			m.bodyInput.Blur()
 873			m.signatureInput.Blur()
 874			m.spellShow = false
 875			m.spellSuggestions = nil
 876
 877			switch m.focusIndex {
 878			case focusFrom:
 879				if m.isCatchAllAccount() {
 880					cmds = append(cmds, m.fromInput.Focus())
 881				}
 882			case focusTo:
 883				cmds = append(cmds, m.toInput.Focus())
 884			case focusCc:
 885				cmds = append(cmds, m.ccInput.Focus())
 886			case focusBcc:
 887				cmds = append(cmds, m.bccInput.Focus())
 888			case focusSubject:
 889				cmds = append(cmds, m.subjectInput.Focus())
 890			case focusBody:
 891				cmds = append(cmds, m.bodyInput.Focus())
 892			case focusSignature:
 893				cmds = append(cmds, m.signatureInput.Focus())
 894			}
 895			return m, tea.Batch(cmds...)
 896
 897		case kb.Composer.Delete:
 898			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
 899				m.removeSelectedAttachment()
 900				return m, nil
 901			}
 902
 903		case keyEnter, " ":
 904			switch m.focusIndex {
 905			case focusFrom:
 906				if msg.String() == keyEnter && len(m.accounts) > 1 {
 907					m.showAccountPicker = true
 908					return m, nil
 909				}
 910				if m.isCatchAllAccount() && msg.String() == " " {
 911					break
 912				}
 913				return m, nil
 914			case focusAttachment:
 915				if msg.String() == keyEnter {
 916					return m, func() tea.Msg { return GoToFilePickerMsg{} }
 917				}
 918			case focusEncryptSMIME:
 919				if msg.String() == keyEnter || msg.String() == " " {
 920					m.encryptSMIME = !m.encryptSMIME
 921				}
 922				return m, nil
 923
 924			case focusSend:
 925				if msg.String() == keyEnter {
 926					if !m.canSendEmail() {
 927						return m, m.showComposerNotice(t("composer.invalid_email_fields"))
 928					}
 929					if !m.hasAnyRecipient() {
 930						return m, m.showComposerNotice(t("composer.recipient_required"))
 931					}
 932					acc := m.getSelectedAccount()
 933					accountID := ""
 934					if acc != nil {
 935						accountID = acc.ID
 936					}
 937					fromOverride := ""
 938					if m.isCatchAllAccount() {
 939						fromOverride = m.fromInput.Value()
 940					}
 941					return m, func() tea.Msg {
 942						return SendEmailMsg{
 943							To:              m.toInput.Value(),
 944							Cc:              m.ccInput.Value(),
 945							Bcc:             m.bccInput.Value(),
 946							Subject:         m.subjectInput.Value(),
 947							Body:            m.bodyInput.Value(),
 948							AttachmentPaths: m.attachmentPaths,
 949							AccountID:       accountID,
 950							FromOverride:    fromOverride,
 951							QuotedText:      m.quotedText,
 952							InReplyTo:       m.inReplyTo,
 953							References:      m.references,
 954							Signature:       m.signatureInput.Value(),
 955							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
 956							EncryptSMIME:    m.encryptSMIME,
 957							SignPGP:         acc != nil && acc.PGPSignByDefault,
 958						}
 959					}
 960				}
 961			}
 962		}
 963	}
 964
 965	switch m.focusIndex {
 966	case focusFrom:
 967		if m.isCatchAllAccount() {
 968			previousFromValue := m.fromInput.Value()
 969			m.fromInput, cmd = m.fromInput.Update(msg)
 970			cmds = append(cmds, cmd)
 971			if m.fromInput.Value() != previousFromValue {
 972				m.fromError = ""
 973			}
 974		}
 975	case focusTo:
 976		previousToValue := m.toInput.Value()
 977		m.toInput, cmd = m.toInput.Update(msg)
 978		cmds = append(cmds, cmd)
 979
 980		// Check if To field value changed and update suggestions
 981		currentValue := m.toInput.Value()
 982		if currentValue != m.lastToValue {
 983			if currentValue != previousToValue {
 984				m.toError = ""
 985			}
 986			m.lastToValue = currentValue
 987
 988			// Extract the last comma-separated part for searching
 989			parts := strings.Split(currentValue, ",")
 990			lastPart := strings.TrimSpace(parts[len(parts)-1])
 991
 992			if len(lastPart) >= 2 {
 993				m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID())
 994				m.showSuggestions = len(m.suggestions) > 0
 995				m.selectedSuggestion = 0
 996			} else {
 997				m.showSuggestions = false
 998				m.suggestions = nil
 999			}
1000		}
1001	case focusCc:
1002		previousCcValue := m.ccInput.Value()
1003		m.ccInput, cmd = m.ccInput.Update(msg)
1004		cmds = append(cmds, cmd)
1005		if m.ccInput.Value() != previousCcValue {
1006			m.ccError = ""
1007		}
1008	case focusBcc:
1009		previousBccValue := m.bccInput.Value()
1010		m.bccInput, cmd = m.bccInput.Update(msg)
1011		cmds = append(cmds, cmd)
1012		if m.bccInput.Value() != previousBccValue {
1013			m.bccError = ""
1014		}
1015	case focusSubject:
1016		m.subjectInput, cmd = m.subjectInput.Update(msg)
1017		cmds = append(cmds, cmd)
1018	case focusBody:
1019		prevBody := m.bodyInput.Value()
1020		prevRow := m.bodyInput.Line()
1021		prevCol := m.bodyInput.Column()
1022		m.bodyInput, cmd = m.bodyInput.Update(msg)
1023		cmds = append(cmds, cmd)
1024		// Only recompute suggestions when the body state actually changes.
1025		// Cursor-blink ticks otherwise reset spellSelected to 0 every blink.
1026		if m.bodyInput.Value() != prevBody ||
1027			m.bodyInput.Line() != prevRow ||
1028			m.bodyInput.Column() != prevCol {
1029			m.updateSpellSuggestions()
1030		}
1031	case focusSignature:
1032		m.signatureInput, cmd = m.signatureInput.Update(msg)
1033		cmds = append(cmds, cmd)
1034	}
1035
1036	return m, tea.Batch(cmds...)
1037}
1038
1039func (m *Composer) View() tea.View { //nolint:gocyclo
1040	var composerView strings.Builder
1041	var button string
1042	ck := config.Keybinds.Composer
1043
1044	if m.focusIndex == focusSend {
1045		button = focusedStyle.Render("[ " + t("composer.send") + " ]")
1046	} else {
1047		button = blurredStyle.Render("[ " + t("composer.send") + " ]")
1048	}
1049
1050	// From field with account selector
1051	fromAddr := m.getFromAddress()
1052	var fromField string
1053	if m.isCatchAllAccount() { //nolint:gocritic
1054		fromAddrView := m.fromInput.View()
1055		if len(m.accounts) > 1 {
1056			if m.focusIndex == focusFrom {
1057				fromField = focusedStyle.Render(fmt.Sprintf("> %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.enter_to_switch")+"]")
1058			} else {
1059				fromField = blurredStyle.Render(fmt.Sprintf("  %s ", t("composer.from"))) + fromAddrView + " " + blurredStyle.Render("["+t("composer.switchable")+"]")
1060			}
1061		} else {
1062			fromField = "  " + t("composer.from") + " " + fromAddrView
1063		}
1064		if m.fromError != "" {
1065			fromField += "\n" + composerErrorStyle.Render(m.fromError)
1066		}
1067	} else if len(m.accounts) > 1 {
1068		if m.focusIndex == focusFrom {
1069			fromField = focusedStyle.Render(fmt.Sprintf("> %s %s [%s]", t("composer.from"), fromAddr, t("composer.enter_to_switch")))
1070		} else {
1071			fromField = blurredStyle.Render(fmt.Sprintf("  %s %s [%s]", t("composer.from"), fromAddr, t("composer.switchable")))
1072		}
1073	} else if fromAddr != "" {
1074		fromField = "  " + t("composer.from") + " " + emailRecipientStyle.Render(fromAddr)
1075	} else {
1076		fromField = blurredStyle.Render(fmt.Sprintf("  %s (%s)", t("composer.from"), t("composer.no_account")))
1077	}
1078
1079	var attachmentField string
1080	if len(m.attachmentPaths) == 0 {
1081		attachmentText := fmt.Sprintf("%s (%s)", t("composer.attachments_none"), t("composer.enter_to_add"))
1082		if m.focusIndex == focusAttachment {
1083			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.attachments"), attachmentText))
1084		} else {
1085			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
1086		}
1087	} else {
1088		var b strings.Builder
1089		headerPrefix := "  "
1090		headerStyle := blurredStyle
1091		if m.focusIndex == focusAttachment {
1092			headerPrefix = "> "
1093			headerStyle = focusedStyle
1094		}
1095		b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
1096		for i, p := range m.attachmentPaths {
1097			cursor := "    "
1098			style := blurredStyle
1099			if m.focusIndex == focusAttachment && i == m.attachmentCursor {
1100				cursor = "  > "
1101				style = focusedStyle
1102			}
1103			b.WriteString("\n")
1104			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
1105		}
1106		attachmentField = b.String()
1107	}
1108
1109	encToggle := "[ ]"
1110	if m.encryptSMIME {
1111		encToggle = "[x]"
1112	}
1113	encField := blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.encrypt_smime"), encToggle))
1114	if m.focusIndex == focusEncryptSMIME {
1115		encField = focusedStyle.Render(fmt.Sprintf("> %s %s", t("composer.encrypt_smime"), encToggle))
1116	}
1117
1118	// Build To field with suggestions
1119	toFieldView := m.toInput.View()
1120	if m.toError != "" {
1121		toFieldView += "\n" + composerErrorStyle.Render(m.toError)
1122	}
1123	if m.showSuggestions && len(m.suggestions) > 0 {
1124		var suggestionsBuilder strings.Builder
1125		suggestionWidth := suggestionDisplayWidth(m.width)
1126		for i, s := range m.suggestions {
1127			display := suggestionDisplay(s, suggestionWidth)
1128			if i == m.selectedSuggestion {
1129				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
1130			} else {
1131				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
1132			}
1133		}
1134		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
1135	}
1136
1137	ccFieldView := m.ccInput.View()
1138	if m.ccError != "" {
1139		ccFieldView += "\n" + composerErrorStyle.Render(m.ccError)
1140	}
1141
1142	bccFieldView := m.bccInput.View()
1143	if m.bccError != "" {
1144		bccFieldView += "\n" + composerErrorStyle.Render(m.bccError)
1145	}
1146
1147	// Signature field label
1148	var signatureLabel string
1149	if m.focusIndex == focusSignature {
1150		signatureLabel = focusedStyle.Render(t("composer.signature") + ":")
1151	} else {
1152		signatureLabel = blurredStyle.Render(t("composer.signature") + ":")
1153	}
1154
1155	tip := ""
1156	switch m.focusIndex {
1157	case focusFrom:
1158		tip = "Select the account to send from."
1159	case focusTo:
1160		tip = "Enter recipient email addresses."
1161	case focusCc:
1162		tip = "Carbon copy recipients."
1163	case focusBcc:
1164		tip = "Blind carbon copy recipients."
1165	case focusSubject:
1166		tip = "The subject line of your email."
1167	case focusBody:
1168		if m.spellShow && len(m.spellSuggestions) > 0 {
1169			sk := config.Keybinds.Composer
1170			tip = fmt.Sprintf("Spelling: %s accept • %s/%s navigate • %s dismiss",
1171				sk.SpellAccept, sk.SpellNext, sk.SpellPrev, sk.SpellDismiss)
1172		} else {
1173			tip = "The main content of your email. Markdown and HTML are supported."
1174		}
1175	case focusSignature:
1176		tip = "Your email signature. This will be appended to the end of the email."
1177	case focusAttachment:
1178		tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
1179	case focusEncryptSMIME:
1180		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
1181	case focusSend:
1182		tip = "Press Enter to send the email."
1183	}
1184
1185	bodyView := m.bodyInput.View()
1186	if !m.disableSpellcheck && m.spellChecker != nil && m.spellChecker.Loaded() {
1187		bodyView = spellcheck.Highlight(bodyView, m.spellChecker, -1)
1188	}
1189
1190	composerViewElements := []string{
1191		t("composer.title"),
1192		fromField,
1193		toFieldView,
1194		ccFieldView,
1195		bccFieldView,
1196		m.subjectInput.View(),
1197		bodyView,
1198		signatureLabel,
1199		m.signatureInput.View(),
1200		attachmentStyle.Render(attachmentField),
1201	}
1202	if len(m.attachmentPaths) > 0 {
1203		composerViewElements = append(composerViewElements, "")
1204	}
1205	composerViewElements = append(composerViewElements,
1206		smimeToggleStyle.Render(encField),
1207		button,
1208		"",
1209	)
1210
1211	if !m.hideTips && tip != "" {
1212		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
1213	}
1214
1215	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
1216	helpText := t("composer.help")
1217	for _, pk := range m.pluginKeyBindings {
1218		helpText += " • " + pk.Key + ": " + pk.Description
1219	}
1220	if m.pluginStatus != "" {
1221		helpText += " • " + m.pluginStatus
1222	}
1223	helpView := helpStyle.Render(helpText)
1224
1225	if m.height > 0 {
1226		currentHeight := lipgloss.Height(mainContent) + lipgloss.Height(helpView)
1227		gap := m.height - currentHeight
1228		if gap >= 0 {
1229			mainContent += strings.Repeat("\n", gap+1)
1230		} else {
1231			mainContent += "\n"
1232		}
1233	} else {
1234		mainContent += "\n\n"
1235	}
1236
1237	composerView.WriteString(mainContent)
1238	composerView.WriteString(helpView)
1239
1240	// Plugin prompt overlay
1241	if m.showPluginPrompt {
1242		dialog := DialogBoxStyle.Render(
1243			lipgloss.JoinVertical(lipgloss.Left,
1244				m.pluginPromptPlaceholder,
1245				"",
1246				m.pluginPromptInput.View(),
1247				"",
1248				HelpStyle.Render("enter: submit • esc: cancel"),
1249			),
1250		)
1251		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1252	}
1253
1254	// Account picker overlay
1255	if m.showAccountPicker {
1256		var accountList strings.Builder
1257		accountList.WriteString("Select Account:\n\n")
1258		for i, acc := range m.accounts {
1259			display := acc.GetSendAsEmail()
1260			if acc.Name != "" {
1261				display = fmt.Sprintf("%s (%s)", acc.Name, acc.GetSendAsEmail())
1262			}
1263			if i == m.selectedAccountIdx {
1264				accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display)))
1265			} else {
1266				accountList.WriteString(itemStyle.Render(fmt.Sprintf("  %s", display)))
1267			}
1268			accountList.WriteString("\n")
1269		}
1270		accountList.WriteString("\n")
1271		accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel"))
1272
1273		dialog := DialogBoxStyle.Render(accountList.String())
1274		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1275	}
1276
1277	if m.confirmingExit {
1278		dialog := DialogBoxStyle.Render(
1279			lipgloss.JoinVertical(lipgloss.Center,
1280				t("composer.exit_confirm"),
1281				HelpStyle.Render("\n(y/n)"),
1282			),
1283		)
1284		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1285	}
1286
1287	if m.showNotice {
1288		dialog := DialogBoxStyle.Render(
1289			lipgloss.JoinVertical(lipgloss.Center,
1290				dangerStyle.Render(m.noticeText),
1291				HelpStyle.Render("\nenter/esc: close"),
1292			),
1293		)
1294		return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog))
1295	}
1296
1297	out := composerView.String()
1298	if m.spellShow && len(m.spellSuggestions) > 0 && m.focusIndex == focusBody {
1299		out = m.overlaySpellPopup(out, composerViewElements)
1300	}
1301	return tea.NewView(out)
1302}
1303
1304// overlaySpellPopup floats the suggestion box at the body cursor position
1305// in the rendered composer view. It returns the view unchanged when the
1306// cursor can't be located.
1307func (m *Composer) overlaySpellPopup(view string, elementsBeforeBody []string) string {
1308	// Body is the 7th element (index 6) of composerViewElements: title,
1309	// from, to, cc, bcc, subject, body, ...
1310	const bodyIdx = 6
1311	if bodyIdx > len(elementsBeforeBody) {
1312		return view
1313	}
1314	bodyStartRow := 0
1315	for i := 0; i < bodyIdx; i++ {
1316		bodyStartRow += lipgloss.Height(elementsBeforeBody[i])
1317	}
1318
1319	li := m.bodyInput.LineInfo()
1320	const promptWidth = 2 // "> "
1321	cursorRow := bodyStartRow + li.RowOffset
1322	cursorCol := li.CharOffset + promptWidth
1323
1324	popup := m.renderSpellPopupLines()
1325	if len(popup) == 0 {
1326		return view
1327	}
1328
1329	// Anchor below cursor. If popup would clip the bottom, raise it above
1330	// the cursor row instead.
1331	anchorRow := cursorRow + 1
1332	if m.height > 0 && anchorRow+len(popup) > m.height-1 && cursorRow-len(popup) >= 0 {
1333		anchorRow = cursorRow - len(popup)
1334	}
1335	anchorCol := cursorCol
1336	popupWidth := lipgloss.Width(popup[0])
1337	if m.width > 0 && anchorCol+popupWidth > m.width {
1338		anchorCol = max(0, m.width-popupWidth)
1339	}
1340
1341	return overlay.Block(view, popup, anchorRow, anchorCol)
1342}
1343
1344// renderSpellPopupLines builds the styled, bordered suggestion box and
1345// returns its rendered lines. Each row carries an "abc" badge to mirror
1346// the language-server look familiar from VSCode.
1347func (m *Composer) renderSpellPopupLines() []string {
1348	if !m.spellShow || len(m.spellSuggestions) == 0 {
1349		return nil
1350	}
1351	maxWidth := 0
1352	for _, s := range m.spellSuggestions {
1353		if w := len(s); w > maxWidth {
1354			maxWidth = w
1355		}
1356	}
1357	rowWidth := maxWidth + 6 // " abc " badge + word + trailing space
1358
1359	iconStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
1360	rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
1361	selStyle := lipgloss.NewStyle().Background(lipgloss.Color("24")).Foreground(lipgloss.Color("231"))
1362
1363	var rows []string
1364	for i, s := range m.spellSuggestions {
1365		text := " " + iconStyle.Render("abc") + " " + s
1366		pad := rowWidth - lipgloss.Width(text)
1367		if pad < 0 {
1368			pad = 0
1369		}
1370		text += strings.Repeat(" ", pad)
1371		if i == m.spellSelected {
1372			rows = append(rows, selStyle.Render(text))
1373		} else {
1374			rows = append(rows, rowStyle.Render(text))
1375		}
1376	}
1377	box := suggestionBoxStyle.Render(strings.Join(rows, "\n"))
1378	return strings.Split(box, "\n")
1379}
1380
1381// SetAccounts sets the available accounts for sending.
1382func (m *Composer) SetAccounts(accounts []config.Account) {
1383	m.accounts = accounts
1384	if m.selectedAccountIdx >= len(accounts) {
1385		m.selectedAccountIdx = 0
1386	}
1387	m.updateSignature()
1388}
1389
1390// SetSelectedAccount sets the selected account by ID.
1391func (m *Composer) SetSelectedAccount(accountID string) {
1392	for i, acc := range m.accounts {
1393		if acc.ID == accountID {
1394			m.selectedAccountIdx = i
1395			m.updateSignature()
1396			return
1397		}
1398	}
1399}
1400
1401// GetSelectedAccountID returns the ID of the currently selected account.
1402func (m *Composer) GetSelectedAccountID() string {
1403	if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
1404		return m.accounts[m.selectedAccountIdx].ID
1405	}
1406	return ""
1407}
1408
1409// GetDraftID returns the draft ID for this composer.
1410func (m *Composer) GetDraftID() string {
1411	return m.draftID
1412}
1413
1414// SetDraftID sets the draft ID (for loading existing drafts).
1415func (m *Composer) SetDraftID(id string) {
1416	m.draftID = id
1417}
1418
1419// GetTo returns the current To field value.
1420func (m *Composer) GetTo() string {
1421	return m.toInput.Value()
1422}
1423
1424// SetTo updates the To field with new content.
1425func (m *Composer) SetTo(to string) {
1426	m.toInput.SetValue(to)
1427}
1428
1429// GetCc returns the current Cc field value.
1430func (m *Composer) GetCc() string {
1431	return m.ccInput.Value()
1432}
1433
1434// SetCc updates the Cc field with new content.
1435func (m *Composer) SetCc(cc string) {
1436	m.ccInput.SetValue(cc)
1437}
1438
1439// GetBcc returns the current Bcc field value.
1440func (m *Composer) GetBcc() string {
1441	return m.bccInput.Value()
1442}
1443
1444// SetBcc updates the Bcc field with new content.
1445func (m *Composer) SetBcc(bcc string) {
1446	m.bccInput.SetValue(bcc)
1447}
1448
1449// GetSubject returns the current Subject field value.
1450func (m *Composer) GetSubject() string {
1451	return m.subjectInput.Value()
1452}
1453
1454// SetSubject updates the Subject field with new content.
1455func (m *Composer) SetSubject(subject string) {
1456	m.subjectInput.SetValue(subject)
1457}
1458
1459// GetBody returns the current Body field value.
1460func (m *Composer) GetBody() string {
1461	return m.bodyInput.Value()
1462}
1463
1464// SetBody updates the Body field with new content.
1465func (m *Composer) SetBody(body string) {
1466	m.bodyInput.SetValue(body)
1467}
1468
1469// GetAttachmentPaths returns the current attachment paths.
1470func (m *Composer) GetAttachmentPaths() []string {
1471	return m.attachmentPaths
1472}
1473
1474// GetSignature returns the current signature value.
1475func (m *Composer) GetSignature() string {
1476	return m.signatureInput.Value()
1477}
1478
1479// SetReplyContext sets the reply context for the draft.
1480func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
1481	m.inReplyTo = inReplyTo
1482	m.references = references
1483}
1484
1485// SetQuotedText sets the hidden quoted text that will be appended when sending.
1486func (m *Composer) SetQuotedText(text string) {
1487	m.quotedText = text
1488}
1489
1490// GetQuotedText returns the hidden quoted text.
1491func (m *Composer) GetQuotedText() string {
1492	return m.quotedText
1493}
1494
1495// GetInReplyTo returns the In-Reply-To header value.
1496func (m *Composer) GetInReplyTo() string {
1497	return m.inReplyTo
1498}
1499
1500// GetReferences returns the References header values.
1501func (m *Composer) GetReferences() []string {
1502	return m.references
1503}
1504
1505// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
1506func (m *Composer) SetPluginStatus(status string) {
1507	m.pluginStatus = status
1508}
1509
1510// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1511func (m *Composer) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1512	m.pluginKeyBindings = bindings
1513}
1514
1515// ShowPluginPrompt activates the plugin prompt overlay with the given placeholder text.
1516func (m *Composer) ShowPluginPrompt(placeholder string) {
1517	m.pluginPromptPlaceholder = placeholder
1518	m.pluginPromptInput = textinput.New()
1519	m.pluginPromptInput.Placeholder = placeholder
1520	m.pluginPromptInput.Prompt = "> "
1521	m.pluginPromptInput.CharLimit = 256
1522	m.pluginPromptInput.Focus()
1523	m.showPluginPrompt = true
1524}
1525
1526// HidePluginPrompt deactivates the plugin prompt overlay.
1527func (m *Composer) HidePluginPrompt() {
1528	m.showPluginPrompt = false
1529}
1530
1531// HasContent reports whether the composer holds anything worth persisting.
1532// It is used to avoid saving empty drafts when the user quits the composer.
1533func (m *Composer) HasContent() bool {
1534	return m.hasAnyRecipient() ||
1535		strings.TrimSpace(m.subjectInput.Value()) != "" ||
1536		strings.TrimSpace(m.bodyInput.Value()) != "" ||
1537		len(m.attachmentPaths) > 0
1538}
1539
1540// ToDraft converts the composer state to a Draft for saving.
1541func (m *Composer) ToDraft() config.Draft {
1542	return config.Draft{
1543		ID:              m.draftID,
1544		To:              m.toInput.Value(),
1545		Cc:              m.ccInput.Value(),
1546		Bcc:             m.bccInput.Value(),
1547		Subject:         m.subjectInput.Value(),
1548		Body:            m.bodyInput.Value(),
1549		AttachmentPaths: m.attachmentPaths,
1550		AccountID:       m.GetSelectedAccountID(),
1551		FromOverride:    m.fromInput.Value(),
1552		InReplyTo:       m.inReplyTo,
1553		References:      m.references,
1554		QuotedText:      m.quotedText,
1555	}
1556}
1557
1558// NewComposerFromDraft creates a composer from an existing draft.
1559func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTips bool) *Composer {
1560	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body, hideTips)
1561	m.ccInput.SetValue(draft.Cc)
1562	m.bccInput.SetValue(draft.Bcc)
1563	m.draftID = draft.ID
1564	m.attachmentPaths = draft.AttachmentPaths
1565	m.attachmentNames = make(map[string]string, len(m.attachmentPaths))
1566	for _, path := range m.attachmentPaths {
1567		m.attachmentNames[path] = formatAttachmentName(path)
1568	}
1569	m.clampAttachmentCursor()
1570	if m.isCatchAllAccount() && draft.FromOverride != "" {
1571		m.fromInput.SetValue(draft.FromOverride)
1572	}
1573	m.inReplyTo = draft.InReplyTo
1574	m.references = draft.References
1575	m.quotedText = draft.QuotedText
1576	return m
1577}