feat: dynamic composer height (#300)

Uğur Tafralı and drew created

Co-authored-by: drew <me@andrinoff.com>
Signed-off-by: drew <me@andrinoff.com>

Change summary

tui/composer.go      | 19 +++++++++++++++++++
tui/composer_test.go | 35 +++++++++++++++++++++++++++++++++++
tui/inbox.go         | 36 ++++++++++++++++++------------------
3 files changed, 72 insertions(+), 18 deletions(-)

Detailed changes

tui/composer.go 🔗

@@ -193,6 +193,25 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.subjectInput.SetWidth(inputWidth)
 		m.bodyInput.SetWidth(inputWidth)
 		m.signatureInput.SetWidth(inputWidth)
+		if msg.Height > 0 {
+			// Fixed rows: title, from, to, cc, bcc, subject, sig label,
+			// attachment, smime, button, blank, tip, help = 13
+			const fixedRows = 13
+			available := msg.Height - fixedRows
+			if available < 6 {
+				available = 6
+			}
+			bodyHeight := (available * 55) / 100
+			sigHeight := (available * 15) / 100
+			if bodyHeight < 3 {
+				bodyHeight = 3
+			}
+			if sigHeight < 2 {
+				sigHeight = 2
+			}
+			m.bodyInput.SetHeight(bodyHeight)
+			m.signatureInput.SetHeight(sigHeight)
+		}
 
 	case FileSelectedMsg:
 		// Avoid duplicates

tui/composer_test.go 🔗

@@ -286,3 +286,38 @@ func TestComposerSetSelectedAccount(t *testing.T) {
 		t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx)
 	}
 }
+
+// TestComposerDynamicHeight verifies that window resize updates textarea heights.
+func TestComposerDynamicHeight(t *testing.T) {
+	composer := NewComposer("", "", "", "", false)
+
+	model, _ := composer.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
+	composer = model.(*Composer)
+
+	if composer.height != 40 {
+		t.Errorf("Expected height 40, got %d", composer.height)
+	}
+
+	bodyH := composer.bodyInput.Height()
+	sigH := composer.signatureInput.Height()
+
+	if bodyH <= 3 {
+		t.Errorf("Expected bodyInput height > 3, got %d", bodyH)
+	}
+	if sigH <= 1 {
+		t.Errorf("Expected signatureInput height > 1, got %d", sigH)
+	}
+	if bodyH <= sigH {
+		t.Errorf("Expected bodyInput height (%d) > signatureInput height (%d)", bodyH, sigH)
+	}
+
+	// Small window: heights should not go below minimums
+	model, _ = composer.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
+	composer = model.(*Composer)
+	if composer.bodyInput.Height() < 3 {
+		t.Errorf("bodyInput height should be at least 3, got %d", composer.bodyInput.Height())
+	}
+	if composer.signatureInput.Height() < 2 {
+		t.Errorf("signatureInput height should be at least 2, got %d", composer.signatureInput.Height())
+	}
+}

tui/inbox.go 🔗

@@ -24,7 +24,7 @@ var (
 	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
 )
 
-var dateStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
+var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
 var senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Bold(true)
 
 type item struct {
@@ -184,23 +184,23 @@ type AccountTab struct {
 }
 
 type Inbox struct {
-	list             list.Model
-	isFetching       bool
-	isRefreshing     bool
-	emailsCount      int
-	accounts         []config.Account
-	emailsByAccount  map[string][]fetcher.Email
-	allEmails        []fetcher.Email
-	tabs             []AccountTab
-	activeTabIndex   int
-	width            int
-	height           int
-	currentAccountID string // Empty means "ALL"
-	emailCountByAcct  map[string]int
-	mailbox             MailboxKind
-	folderName          string // Custom folder name override for title
-	noMoreByAccount     map[string]bool // Per-account: true when pagination returns 0 results
-	extraShortHelpKeys  []key.Binding
+	list               list.Model
+	isFetching         bool
+	isRefreshing       bool
+	emailsCount        int
+	accounts           []config.Account
+	emailsByAccount    map[string][]fetcher.Email
+	allEmails          []fetcher.Email
+	tabs               []AccountTab
+	activeTabIndex     int
+	width              int
+	height             int
+	currentAccountID   string // Empty means "ALL"
+	emailCountByAcct   map[string]int
+	mailbox            MailboxKind
+	folderName         string          // Custom folder name override for title
+	noMoreByAccount    map[string]bool // Per-account: true when pagination returns 0 results
+	extraShortHelpKeys []key.Binding
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {