diff --git a/CHANGELOG.md b/CHANGELOG.md index d2de736..325adf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org). - `mail archive --json` now emits a stable `{"archived":[...]}` action result after every requested message is archived. Existing `mail list --json` and `cal --json` array payloads remain unchanged. +- Redesigned TUI inbox and calendar lists for scanability: mail rows now show + sender, subject, received time, unread state, and selection; calendar events + are grouped by day with clear all-day, same-day, and multi-day labels. +- TUI panels now use the available terminal width instead of shrinking to the + longest rendered row. +- TUI inbox and calendar lists now stay within the terminal height while keeping + the selected row visible during navigation. ## [0.3.0] - 2026-08-01 diff --git a/README.md b/README.md index 3e7a2d3..b66bc31 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,9 @@ an `>` marker, and unread messages include a `NEW` label, so state stays clear without color. Colors adapt to light or dark terminals and Lip Gloss falls back to the same readable text hierarchy on terminals without color support. Narrow terminals use compact panels and two-line inbox rows to preserve message context. +On wider terminals, list panels use the available width so longer calendar and +message details remain easy to scan. Lists stay within the terminal height, and +`j` / `k` keeps the selected row visible while navigating. | Key | Action | | ---------------------- | ------------------------------------------- | @@ -120,6 +123,12 @@ Open messages use `j` / `k`, arrow keys, `g`, and `G` to scroll their full contents. Their footer lists the detail-specific bindings; list footers and expanded help list only bindings that apply to the active mail or calendar mode. +Mail rows show selection, unread state, sender, subject, and received time. The +header identifies whether the TUI is showing Inbox or all mail, and list columns +compact intentionally on narrow terminals. Calendar events are grouped by start +day; all-day events, same-day meetings, and multi-day events use distinct time +labels so upcoming commitments remain easy to scan. + ## Why this over WorkIQ directly? WorkIQ's chat answers questions about your mail. `gh-msft` adds what a chat can't: diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 48efd54..8db659f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -62,6 +62,11 @@ type Model struct { height int } +type calendarListRow struct { + eventIndex int + text string +} + // New builds a model for the given mail provider. When all is true it loads all // mail instead of only the inbox. func New(provider mail.Provider, top int, all bool) Model { @@ -373,13 +378,18 @@ func (m Model) View() string { // viewMail renders the inbox list. func (m Model) viewMail() string { var b strings.Builder - if len(m.messages) == 0 { - b.WriteString(styles.empty.Render("No messages in this view.")) + empty := "No messages in Inbox." + if m.all { + empty = "No messages in all mail." + } + b.WriteString(styles.empty.Render(empty)) b.WriteString("\n") } - for i, msg := range m.messages { - line := m.mailLine(i, msg) + start, end := m.listRange(len(m.messages), m.cursor) + for i := start; i < end; i++ { + msg := m.messages[i] + line := m.mailRow(i, msg) switch { case i == m.cursor: line = styles.selected.Render(line) @@ -391,41 +401,180 @@ func (m Model) viewMail() string { } b.WriteString(m.footer()) - return m.screen(m.chrome("Inbox", len(m.messages)), b.String()) + return m.screen(m.chrome(m.mailTitle(), -1), b.String()) } -// viewCalendar renders a simple list of upcoming calendar events. A richer -// per-day layout will replace it later. func (m Model) viewCalendar() string { var b strings.Builder - if len(m.events) == 0 { b.WriteString(styles.empty.Render("No upcoming events.")) b.WriteString("\n") - } - for i, e := range m.events { - cursor := " " - if i == m.cursor { - cursor = "> " + } else { + rows := m.calendarListRows() + selectedRow := 0 + for i, row := range rows { + if row.eventIndex == m.cursor { + selectedRow = i + break + } } - whenWidth := 28 - if m.isNarrow() { - whenWidth = 14 + start, end := m.listRange(len(rows), selectedRow) + for _, row := range rows[start:end] { + b.WriteString(row.text) + b.WriteString("\n") } - subjectWidth := m.listWidth() - len(cursor) - whenWidth - 1 - if subjectWidth < 8 { - subjectWidth = 8 + } + + b.WriteString(m.footer()) + return m.screen(m.chrome(m.calendarTitle(), -1), b.String()) +} + +func (m Model) calendarListRows() []calendarListRow { + rows := make([]calendarListRow, 0, len(m.events)*2) + lastDay := "" + for i, e := range m.events { + day := calendarDayLabel(e) + if day != lastDay { + if lastDay != "" { + rows = append(rows, calendarListRow{eventIndex: -1, text: ""}) + } + rows = append(rows, calendarListRow{eventIndex: -1, text: styles.header.Render(day)}) + lastDay = day } - line := fmt.Sprintf("%s%-*s %s", cursor, whenWidth, truncate(eventWhen(e), whenWidth), truncate(e.Subject, subjectWidth)) + line := m.calendarRow(i, e) if i == m.cursor { line = styles.selected.Render(line) } - b.WriteString(line) - b.WriteString("\n") + rows = append(rows, calendarListRow{eventIndex: i, text: line}) } + return rows +} - b.WriteString(m.footer()) - return m.screen(m.chrome("Calendar", len(m.events)), b.String()) +func (m Model) mailTitle() string { + scope := "Inbox" + if m.all { + scope = "All mail" + } + return fmt.Sprintf("Inbox · %d %s · %s", len(m.messages), pluralize(len(m.messages), "message"), scope) +} + +func (m Model) calendarTitle() string { + return fmt.Sprintf("Upcoming · %d %s", len(m.events), pluralize(len(m.events), "event")) +} + +func pluralize(count int, singular string) string { + if count != 1 { + return singular + "s" + } + return singular +} + +func (m Model) mailRow(index int, msg mail.Message) string { + cursor := " " + if index == m.cursor { + cursor = "> " + } + state := " " + if !msg.IsRead { + state = "NEW " + } + from := msg.From.Name + if from == "" { + from = msg.From.Email + } + width := m.listWidth() + if m.isNarrow() { + senderWidth := maxInt(4, width-len(cursor)-len(state)-6) + firstLine := fmt.Sprintf("%s%s%s %s", + cursor, + state, + truncate(from, senderWidth), + receivedTime(msg), + ) + secondLine := " " + truncate(msg.Subject, maxInt(4, width-4)) + return truncate(firstLine, width) + "\n" + truncate(secondLine, width) + } + senderWidth := minInt(22, maxInt(12, width/4)) + return truncate(cursor+state+" "+padRight(truncate(from, senderWidth), senderWidth)+" "+ + truncate(msg.Subject, m.subjectWidth())+" "+receivedDateTime(msg), width) +} + +func (m Model) calendarRow(index int, e calendar.Event) string { + cursor := " " + if index == m.cursor { + cursor = "> " + } + width := m.listWidth() + if m.isNarrow() { + timeWidth := minInt(7, maxInt(4, width/2)) + subjectWidth := maxInt(4, width-len(cursor)-timeWidth-1) + return truncate(fmt.Sprintf("%s%-*s %s", cursor, timeWidth, truncate(eventTimeLabel(e, true), timeWidth), truncate(e.Subject, subjectWidth)), width) + } + timeWidth := 20 + organizerWidth := 0 + if e.Organizer != "" && width >= 84 { + organizerWidth = minInt(32, maxInt(20, width/4)) + } + subjectWidth := width - timeWidth - 3 + if organizerWidth > 0 { + subjectWidth -= 3 + organizerWidth + } + subjectWidth = maxInt(12, subjectWidth) + line := cursor + padRight(truncate(eventTimeLabel(e, false), timeWidth), timeWidth) + " " + + truncate(e.Subject, subjectWidth) + if organizerWidth > 0 { + line += " " + styles.metadata.Render("· "+truncate(e.Organizer, organizerWidth)) + } + return line +} + +func receivedDateTime(msg mail.Message) string { + if msg.Received.IsZero() { + return "-" + } + return msg.Received.Time.Local().Format("Jan 02 15:04") +} + +func receivedTime(msg mail.Message) string { + if msg.Received.IsZero() { + return "-" + } + return msg.Received.Time.Local().Format("15:04") +} + +func calendarDayLabel(e calendar.Event) string { + if e.Start.IsZero() { + return "No date" + } + return e.Start.Time.Local().Format("Mon, Jan 02") +} + +func eventTimeLabel(e calendar.Event, compact bool) string { + if e.Start.IsZero() { + return "-" + } + start := e.Start.Time.Local() + if e.IsAllDay { + if e.End.IsZero() || !e.End.Time.Local().After(start.AddDate(0, 0, 1)) { + return "all day" + } + end := e.End.Time.Local().AddDate(0, 0, -1) + if compact { + return "all day→" + end.Format("Jan 02") + } + return "all day → " + end.Format("Jan 02") + } + if e.End.IsZero() { + return start.Format("15:04") + } + end := e.End.Time.Local() + if sameDay(start, end) { + return start.Format("15:04") + "-" + end.Format("15:04") + } + if compact { + return start.Format("Jan 02") + "→" + end.Format("Jan 02") + } + return start.Format("Jan 02 15:04") + " → " + end.Format("Jan 02 15:04") } // eventWhen formats an event's start and end for the calendar list. All-day @@ -547,6 +696,54 @@ func (m Model) detailHeight() int { return 1 } +func (m Model) listHeight() int { + if m.height <= 0 { + return 20 + } + if height := m.height - 6 - m.footerExtraLines(); height > 0 { + return height + } + return 1 +} + +func (m Model) footerExtraLines() int { + extraLines := 0 + if m.status != "" { + extraLines++ + } + var phrases []string + if m.showHelp { + phrases = strings.Split(m.expandedHelp(), " · ") + } else { + phrases = strings.Split(m.compactHelp(), " · ") + } + return extraLines + strings.Count(wrapPhrases(phrases, m.listWidth()-6), "\n") +} + +func (m Model) listRange(total, focus int) (int, int) { + if total == 0 { + return 0, 0 + } + height := m.listHeight() + if total <= height { + return 0, total + } + if focus < 0 { + focus = 0 + } + if focus >= total { + focus = total - 1 + } + start := focus - height + 1 + if start < 0 { + start = 0 + } + if maxStart := total - height; start > maxStart { + start = maxStart + } + return start, start + height +} + func (m Model) maxDetailOffset() int { max := len(m.detailLines()) - m.detailHeight() if max < 0 { @@ -578,37 +775,28 @@ func formatAddrs(as []mail.Address) string { } // subjectWidth returns how many columns the subject may use in a standard-width -// inbox row. Narrow terminals use a two-line row instead. +// inbox row after reserving state, sender, and received-time columns. func (m Model) subjectWidth() int { - const prefix = 31 - w := m.listWidth() - prefix - if w < 8 { - w = 8 - } - return w + senderWidth := minInt(22, maxInt(12, m.listWidth()/4)) + return maxInt(8, m.listWidth()-senderWidth-21) } -func (m Model) mailLine(i int, msg mail.Message) string { - cursor := " " - if i == m.cursor { - cursor = "> " - } - from := msg.From.Name - if from == "" { - from = msg.From.Email - } - if m.isNarrow() { - state := "" - if !msg.IsRead { - state = "NEW " - } - return fmt.Sprintf("%s%s%s\n %s", cursor, state, truncate(from, m.listWidth()-len(cursor)-len(state)), truncate(msg.Subject, m.listWidth()-4)) +func padRight(s string, width int) string { + return s + strings.Repeat(" ", maxInt(0, width-lipgloss.Width(s))) +} + +func minInt(a, b int) int { + if a < b { + return a } - state := " " - if !msg.IsRead { - state = "NEW " + return b +} + +func maxInt(a, b int) int { + if a > b { + return a } - return fmt.Sprintf("%s%s %-22s %s", cursor, state, truncate(from, 22), truncate(msg.Subject, m.subjectWidth())) + return b } func (m Model) loadingView() string { @@ -619,7 +807,14 @@ func (m Model) loadingView() string { message = "Loading all mail…" } content := styles.loading.Render(message) + "\n\n" + styles.help.Render("Help: R refresh · q quit") - return m.screen(m.chrome(m.modeTitle(), -1), content) + title := "Inbox · Loading…" + if m.all { + title = "All mail · Loading…" + } + if m.mode == calendarMode { + title = "Upcoming · Loading…" + } + return m.screen(m.chrome(title, -1), content) } func (m Model) errorView() string { @@ -635,7 +830,7 @@ func (m Model) chrome(title string, count int) string { title = fmt.Sprintf("%s (%d)", title, count) } if m.isNarrow() { - return styles.chrome.Render("gh msft · " + title) + return styles.chrome.Render(truncate("gh msft · "+title, m.listWidth())) } mailTab := styles.inactiveTab.Render("Mail") calendarTab := styles.inactiveTab.Render("Calendar") @@ -644,6 +839,8 @@ func (m Model) chrome(title string, count int) string { } else { calendarTab = styles.activeTab.Render("[Calendar]") } + prefixWidth := lipgloss.Width("gh msft [Mail] [Calendar] ") + title = truncate(title, maxInt(1, m.listWidth()-prefixWidth)) return styles.chrome.Render("gh msft") + " " + mailTab + " " + calendarTab + " " + styles.header.Render(title) } @@ -653,16 +850,16 @@ func (m Model) screen(chrome, content string) string { func (m Model) panel(content string) string { if m.isNarrow() { - return styles.compactPanel.Render(content) + return styles.compactPanel.Width(m.listWidth()).Render(content) } - return styles.panel.Render(content) + return styles.panel.Width(m.listWidth()).Render(content) } func (m Model) errorPanel(content string) string { if m.isNarrow() { - return styles.compactError.Render(content) + return styles.compactError.Width(m.listWidth()).Render(content) } - return styles.errorPanel.Render(content) + return styles.errorPanel.Width(m.listWidth()).Render(content) } func (m Model) modeTitle() string { @@ -719,13 +916,26 @@ func wrapPhrases(phrases []string, width int) string { func truncate(s string, n int) string { s = strings.ReplaceAll(s, "\n", " ") - if len(s) <= n { + if n <= 0 { + return "" + } + if lipgloss.Width(s) <= n { return s } - if n <= 1 { - return s[:n] + if n == 1 { + return "…" + } + var b strings.Builder + width := 0 + for _, r := range s { + runeWidth := lipgloss.Width(string(r)) + if width+runeWidth > n-1 { + break + } + b.WriteRune(r) + width += runeWidth } - return s[:n-1] + "…" + return b.String() + "…" } // Run starts the interactive TUI against the given providers. When all is true diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9080a7d..36d6341 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "fmt" "strings" "testing" @@ -63,9 +64,9 @@ func (f *fakeProvider) Body(ctx context.Context, id string) (string, error) { func sampleMessages() []mail.Message { return []mail.Message{ - {ID: "1", Subject: "First", From: mail.Address{Name: "Alice"}, IsRead: false}, - {ID: "2", Subject: "Second", From: mail.Address{Name: "Bob"}, IsRead: true}, - {ID: "3", Subject: "Third", From: mail.Address{Name: "Carol"}, IsRead: false}, + {ID: "1", Subject: "First", From: mail.Address{Name: "Alice"}, Received: mstime.Parse("2026-01-02T15:00:00Z"), IsRead: false}, + {ID: "2", Subject: "Second", From: mail.Address{Name: "Bob"}, Received: mstime.Parse("2026-01-02T16:00:00Z"), IsRead: true}, + {ID: "3", Subject: "Third", From: mail.Address{Name: "Carol"}, Received: mstime.Parse("2026-01-02T17:00:00Z"), IsRead: false}, } } @@ -280,8 +281,8 @@ func TestCalendarViewRendersEvents(t *testing.T) { if !strings.Contains(out, "Standup") { t.Errorf("calendar view missing event subject; got:\n%s", out) } - if !strings.Contains(out, " - ") { - t.Errorf("calendar view should show start - end range; got:\n%s", out) + if !strings.Contains(out, eventTimeLabel(sampleEvents()[0], false)) { + t.Errorf("calendar view should show the event time range; got:\n%s", out) } m, _ = m.update(eventsLoadedMsg{nil}) _ = m.View() // empty calendar @@ -539,9 +540,9 @@ func TestSubjectWidthTracksResize(t *testing.T) { width int want int }{ - {"unset falls back", 0, 43}, + {"unset falls back", 0, 35}, {"narrow clamps to min", 20, 8}, - {"wide grows", 120, 83}, + {"wide grows", 120, 71}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -583,12 +584,12 @@ func TestRenderedStatesUseDesignSystem(t *testing.T) { { name: "mail list", model: Model{messages: sampleMessages(), mailLoaded: true, width: 100}, - want: []string{"gh msft", "[Mail]", "Inbox (3)", "NEW", "> "}, + want: []string{"gh msft", "[Mail]", "Inbox · 3 messages · Inbox", "NEW", "> "}, }, { name: "calendar list", model: Model{mode: calendarMode, events: sampleEvents(), calLoaded: true, width: 100}, - want: []string{"gh msft", "[Calendar]", "Calendar (2)", "Standup", "Help:"}, + want: []string{"gh msft", "[Calendar]", "Upcoming · 2 events", "Standup", "Help:"}, }, { name: "loading", @@ -624,7 +625,7 @@ func TestNarrowViewRetainsTextualStateIndicators(t *testing.T) { m, _ = m.update(tea.WindowSizeMsg{Width: 30, Height: 24}) out := m.View() - for _, want := range []string{"gh msft · Inbox (3)", "NEW", "> ", "Help:"} { + for _, want := range []string{"gh msft", "NEW", "> ", "Help:"} { if !strings.Contains(out, want) { t.Errorf("narrow View() missing %q; got:\n%s", want, out) } @@ -636,6 +637,201 @@ func TestNarrowViewRetainsTextualStateIndicators(t *testing.T) { } } +func TestMailViewShowsScanColumnsAndScope(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()[:1]}) + m, _ = m.update(tea.WindowSizeMsg{Width: 100, Height: 24}) + + out := m.View() + for _, want := range []string{"Inbox · 1 message · Inbox", "NEW", "Alice", "First", receivedDateTime(m.messages[0])} { + if !strings.Contains(out, want) { + t.Errorf("mail view missing %q:\n%s", want, out) + } + } + + m.all = true + out = m.View() + if !strings.Contains(out, "All mail") { + t.Errorf("all-mail view should identify its scope:\n%s", out) + } +} + +func TestCalendarViewGroupsDaysAndShowsEventKinds(t *testing.T) { + events := []calendar.Event{ + {ID: "same-day", Subject: "Planning", Organizer: "Alice", Start: mstime.Parse("2026-01-02T09:00:00Z"), End: mstime.Parse("2026-01-02T10:00:00Z")}, + {ID: "all-day", Subject: "Conference", IsAllDay: true, Start: mstime.Parse("2026-01-02T00:00:00Z"), End: mstime.Parse("2026-01-03T00:00:00Z")}, + {ID: "multi-day", Subject: "Offsite", Start: mstime.Parse("2026-01-03T09:00:00Z"), End: mstime.Parse("2026-01-05T17:00:00Z")}, + } + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{events}) + m, _ = m.update(tea.WindowSizeMsg{Width: 100, Height: 24}) + + out := m.View() + if got := strings.Count(out, calendarDayLabel(events[0])); got != 1 { + t.Errorf("calendar should have one section per day, got %d:\n%s", got, out) + } + for _, want := range []string{"all day", eventTimeLabel(events[0], false), truncate(eventTimeLabel(events[2], false), 20)} { + if !strings.Contains(out, want) { + t.Errorf("calendar view missing %q:\n%s", want, out) + } + } +} + +func TestNarrowListViewsFitTerminal(t *testing.T) { + tests := []struct { + name string + model Model + }{ + { + name: "mail", + model: func() Model { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()[:1]}) + return m + }(), + }, + { + name: "calendar", + model: func() Model { + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()[:1]}) + return m + }(), + }, + } + for _, tt := range tests { + for _, width := range []int{20, 32} { + t.Run(tt.name+"/"+fmt.Sprint(width), func(t *testing.T) { + m, _ := tt.model.update(tea.WindowSizeMsg{Width: width, Height: 24}) + for _, line := range strings.Split(strings.TrimSuffix(m.View(), "\n"), "\n") { + if got := lipgloss.Width(line); got > width { + t.Errorf("line width = %d, want <= %d: %q", got, width, line) + } + } + }) + } + } +} + +func TestPanelsUseAvailableTerminalWidth(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 24}) + + maxWidth := 0 + for _, line := range strings.Split(strings.TrimSuffix(m.View(), "\n"), "\n") { + maxWidth = maxInt(maxWidth, lipgloss.Width(line)) + } + wantWidth := m.width - 2 // The screen keeps one column of horizontal margin on each side. + if maxWidth != wantWidth { + t.Errorf("widest rendered line = %d, want available width %d", maxWidth, wantWidth) + } +} + +func TestListsStayWithinTerminalHeightAndKeepSelectionVisible(t *testing.T) { + events := make([]calendar.Event, 12) + for i := range events { + events[i] = calendar.Event{ + ID: fmt.Sprintf("event-%d", i), + Subject: fmt.Sprintf("Event %d", i), + Start: mstime.Parse(fmt.Sprintf("2026-01-%02dT09:00:00Z", i+1)), + End: mstime.Parse(fmt.Sprintf("2026-01-%02dT10:00:00Z", i+1)), + } + } + m := New(&fakeProvider{}, len(events), false) + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{events}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 12}) + m.cursor = len(events) - 1 + + out := m.View() + if strings.Contains(out, "Event 0") { + t.Errorf("calendar viewport should not render off-screen events:\n%s", out) + } + if !strings.Contains(out, "Event 11") { + t.Errorf("calendar viewport should keep the selected event visible:\n%s", out) + } + if lines := len(strings.Split(strings.TrimSuffix(out, "\n"), "\n")); lines > m.height { + t.Errorf("calendar View() rendered %d lines, want <= terminal height %d:\n%s", lines, m.height, out) + } +} + +func TestMailListViewportKeepsSelectionVisible(t *testing.T) { + messages := make([]mail.Message, 12) + for i := range messages { + messages[i] = mail.Message{ + ID: fmt.Sprintf("message-%d", i), + Subject: fmt.Sprintf("Message %d", i), + From: mail.Address{Name: "Sender"}, + Received: mstime.Parse(fmt.Sprintf("2026-01-%02dT09:00:00Z", i+1)), + } + } + m := New(&fakeProvider{}, len(messages), false) + m, _ = m.update(messagesLoadedMsg{messages}) + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 12}) + m.cursor = len(messages) - 1 + + out := m.View() + if strings.Contains(out, "Message 0") { + t.Errorf("mail viewport should not render off-screen messages:\n%s", out) + } + if !strings.Contains(out, "Message 11") { + t.Errorf("mail viewport should keep the selected message visible:\n%s", out) + } +} + +func TestWideCalendarRowsKeepUsefulMetadata(t *testing.T) { + m := Model{width: 160} + event := calendar.Event{ + Subject: "Planning the next iteration without hiding the event title", + Organizer: "maxbeizer@github.com", + Start: mstime.Parse("2026-01-02T09:00:00Z"), + End: mstime.Parse("2026-01-02T10:00:00Z"), + } + + out := m.calendarRow(0, event) + for _, want := range []string{event.Subject, event.Organizer} { + if !strings.Contains(out, want) { + t.Errorf("wide calendar row missing %q:\n%s", want, out) + } + } +} + +func TestExpandedHelpStaysWithinTerminalHeight(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m, _ = m.update(messagesLoadedMsg{sampleMessages()}) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 12}) + m.showHelp = true + + out := m.View() + if lines := len(strings.Split(strings.TrimSuffix(out, "\n"), "\n")); lines > m.height { + t.Errorf("expanded help rendered %d lines, want <= terminal height %d:\n%s", lines, m.height, out) + } +} + +func TestTruncateRespectsDisplayWidth(t *testing.T) { + tests := []struct { + name string + input string + width int + want string + }{ + {name: "wide rune", input: "界界", width: 3, want: "界…"}, + {name: "single column", input: "abc", width: 1, want: "…"}, + {name: "unchanged", input: "hello", width: 5, want: "hello"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncate(tt.input, tt.width); got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.width, got, tt.want) + } + }) + } +} + func TestNoColorFallbackRemainsReadable(t *testing.T) { profile := lipgloss.ColorProfile() lipgloss.SetColorProfile(termenv.Ascii) @@ -652,3 +848,16 @@ func TestNoColorFallbackRemainsReadable(t *testing.T) { } } } + +func TestLoadingViewIdentifiesActiveScope(t *testing.T) { + m := New(&fakeProvider{}, 10, true) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if out := m.View(); !strings.Contains(out, "All mail · Loading…") { + t.Errorf("mail loading view missing scope and state:\n%s", out) + } + + m.mode = calendarMode + if out := m.View(); !strings.Contains(out, "Upcoming · Loading…") { + t.Errorf("calendar loading view missing scope and state:\n%s", out) + } +}