diff --git a/README.md b/README.md index d179e19a..cf0f83ff 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,10 @@ identity-wide. Run `hey tui` to launch the interactive terminal UI (it offers to sign you in first if needed). Bare `hey` prints the help — or, logged out at a terminal, runs first-time setup. +Run `hey tui spacious` for a layout with additional breathing room between mail +rows, or `hey tui classic` for the original edge-to-edge layout. Press Ctrl+G while the +TUI is open to switch between them; narrow terminals collapse the additional chrome until +there is room for it again. For identities with multiple linked mail accounts, press Ctrl+A to switch between All Accounts and individual email addresses. Switching cancels requests from the previous account and reloads the active section; diff --git a/docs/demos/tui-layouts.gif b/docs/demos/tui-layouts.gif new file mode 100644 index 00000000..71845da3 Binary files /dev/null and b/docs/demos/tui-layouts.gif differ diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 048dafd5..410c6246 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -103,6 +103,39 @@ func TestHeyTuiOpensTUIWhenAuthenticated(t *testing.T) { } } +func TestHeyTuiSelectsAnInitialLayout(t *testing.T) { + isolateAgents(t) + server := quietServer(t) + original := runTUI + var options tui.Options + runTUI = func(_ *hey.Client, _ *hey.Client, _ string, _ tui.Watchers, got tui.Options) error { + options = got + return nil + } + t.Cleanup(func() { runTUI = original }) + + if _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "environment-token", false, "tui", "spacious"); err != nil { + t.Fatalf("hey tui spacious: %v", err) + } + if options.Layout != tui.LayoutSpacious { + t.Errorf("initial layout = %q, want spacious", options.Layout) + } +} + +func TestHeyTuiRejectsAnUnknownLayout(t *testing.T) { + isolateAgents(t) + server := quietServer(t) + calls := stubRunTUI(t) + + _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "environment-token", false, "tui", "roomy") + if err == nil || !strings.Contains(err.Error(), "layout must be classic or spacious") { + t.Fatalf("hey tui roomy error = %v", err) + } + if *calls != 0 { + t.Errorf("unknown layout launched the TUI %d times", *calls) + } +} + func TestHeyTuiTopicStartsAtTheRequestedThread(t *testing.T) { isolateAgents(t) server := quietServer(t) diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index fabdf414..ad93f8b2 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -32,11 +32,19 @@ func newTuiRunner(use string, hidden bool) *cobra.Command { var instance string var remote bool command := &cobra.Command{ - Use: use, + Use: use + " [classic|spacious]", Short: "Launch the interactive terminal UI", Hidden: hidden, - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + layout := tui.LayoutClassic + if len(args) == 1 { + var err error + layout, err = tui.ParseLayout(args[0]) + if err != nil { + return apierr.ErrUsage(err.Error()) + } + } topicSet := cmd.Flags().Changed("topic") topicTitleSet := cmd.Flags().Changed("topic-title") if topicSet && topicID <= 0 { @@ -51,6 +59,9 @@ func newTuiRunner(use string, hidden bool) *cobra.Command { if remote && !topicSet && !screener { return apierr.ErrUsage("--remote requires --topic or --screener") } + if remote && len(args) == 1 { + return apierr.ErrUsage("a layout cannot be selected when opening an existing TUI with --remote") + } request := tui.OpenRequest{TopicID: topicID, Title: topicTitle, Screener: screener} if accountID, err := strconv.ParseInt(cfg.AccountID, 10, 64); err == nil && accountID > 0 { request.AccountID = accountID @@ -67,7 +78,7 @@ func newTuiRunner(use string, hidden bool) *cobra.Command { if err := requireAuth(); err != nil { return err } - return runTUI(rootSDK, sdk, cfg.AccountID, tuiWatchers(), tui.Options{Open: request, Instance: instance}) + return runTUI(rootSDK, sdk, cfg.AccountID, tuiWatchers(), tui.Options{Open: request, Instance: instance, Layout: layout}) }, } command.Flags().Int64Var(&topicID, "topic", 0, "Open a thread by topic ID") diff --git a/internal/tui/accounts.go b/internal/tui/accounts.go index 94a989c4..dc4ce56d 100644 --- a/internal/tui/accounts.go +++ b/internal/tui/accounts.go @@ -134,13 +134,14 @@ func renderMailAccountPicker(m *model, base string) string { } height := m.contentHeight() + width := m.contentWidth() visible := modalContentRows(height) if len(status) > 0 { visible = max(visible-len(status)-1, 1) } - body := strings.Join(modalListRows(labels, m.mailAccountCursor, modalContentWidth(m.width), visible), "\n") + body := strings.Join(modalListRows(labels, m.mailAccountCursor, modalContentWidth(width), visible), "\n") if len(status) > 0 { body += "\n\n" + strings.Join(status, "\n") } - return overlayModal(base, modalFrame("Select mail account", body, m.width), m.width, height) + return overlayModal(base, modalFrame("Select mail account", body, width), width, height) } diff --git a/internal/tui/accounts_test.go b/internal/tui/accounts_test.go index a6ff2406..a162a878 100644 --- a/internal/tui/accounts_test.go +++ b/internal/tui/accounts_test.go @@ -58,7 +58,7 @@ func TestUnavailableSelectedAccountFailsClosedAndAllowsRecovery(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "99", Watchers{}) + m := newModelWithMailAccounts(root, root, "99") loaded := loadMailAccounts(t.Context(), root, "99")().(mailAccountsLoadedMsg) if !loaded.selectedUnavailable { @@ -85,7 +85,7 @@ func TestAccountDiscoveryFailureCanRetry(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "all", Watchers{}) + m := newModelWithMailAccounts(root, root, "all") m.loading = false failed := loadMailAccounts(t.Context(), root, "all")().(mailAccountsLoadedMsg) @@ -193,7 +193,7 @@ func TestAccountSwitchRebuildsViewsAndCancelsOldGeneration(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "all", Watchers{}) + m := newModelWithMailAccounts(root, root, "all") m.mailAccounts = []mailAccountChoice{ {label: "All Accounts"}, {id: 1, label: "jane@example.com"}, @@ -241,7 +241,7 @@ func TestTopicRequestSwitchesToItsMailAccountBeforeOpening(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "all", Watchers{}) + m := newModelWithMailAccounts(root, root, "all") m.mailAccountsLoaded = true m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 2, label: "jane@company.example"}} m.section = sectionCalendar @@ -282,7 +282,7 @@ func TestScreenerRequestSwitchesToItsMailAccountBeforeOpening(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "all", Watchers{}) + m := newModelWithMailAccounts(root, root, "all") m.mailAccountsLoaded = true m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 2, label: "jane@company.example"}} @@ -415,7 +415,7 @@ func TestFailedAccountSwitchPreservesCurrentViews(t *testing.T) { })) t.Cleanup(server.Close) root := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "token"}, hey.WithMaxRetries(0)) - m := newModelWithMailAccounts(root, root, "all", Watchers{}) + m := newModelWithMailAccounts(root, root, "all") m.mailAccounts = []mailAccountChoice{{label: "All Accounts"}, {id: 1, label: "jane@example.com"}, {id: 2, label: "missing@example.com"}} m.mailAccountPicker = true m.mailAccountCursor = 2 diff --git a/internal/tui/content.go b/internal/tui/content.go index 84d2c38d..3e1ddfa2 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -97,6 +97,7 @@ type contentList struct { scrollOff int width int height int // visible rows (each posting takes 2 lines) + itemGap int // blank rows after a posting in the spacious layout hideSeenState bool selected map[int64]struct{} @@ -396,6 +397,22 @@ func (c *contentList) setSize(w, h int) { c.height = h } +func (c *contentList) setItemGap(gap int) { + c.itemGap = max(gap, 0) + c.ensureVisible() +} + +func (c *contentList) effectiveItemGap() int { + if c.height < 3 { + return 0 + } + return c.itemGap +} + +func (c *contentList) effectiveSectionGap() int { + return c.effectiveItemGap() +} + func (c *contentList) moveUp() { if c.cursor > 0 { c.cursor-- @@ -427,9 +444,9 @@ func (c *contentList) visibleItemsFrom(start int) int { count := 0 height := c.listHeight() for i := start; i < c.itemCount(); i++ { - postingRows := 2 + postingRows := 2 + c.effectiveItemGap() if c.sectionLabelAt(i) != "" { - postingRows++ + postingRows += 1 + c.effectiveSectionGap() } if rows+postingRows > height { break @@ -573,7 +590,10 @@ func (c *contentList) view() string { } else { fmt.Fprintln(&b, sectionHeader(label, c.width)) } - rendered++ + for range c.effectiveSectionGap() { + b.WriteString("\n") + } + rendered += 1 + c.effectiveSectionGap() } // The cursor text takes the accent foreground that applyTheme checked @@ -681,7 +701,10 @@ func (c *contentList) view() string { fmt.Fprintln(&b, line1.String()) fmt.Fprintln(&b, line2.String()) - rendered += 2 + for range c.effectiveItemGap() { + b.WriteString("\n") + } + rendered += 2 + c.effectiveItemGap() } if from := c.coveredFrom(); from >= 0 { @@ -698,11 +721,12 @@ func (c *contentList) coverView(hidden, rowsUsed int) string { hint := fmt.Sprintf("%d hidden · x to peek", hidden) header := hintedSectionHeader(sectionPreviouslySeen.label(), hint, c.width) - rows := c.height - rowsUsed - 1 + sectionGap := c.effectiveSectionGap() + rows := c.height - rowsUsed - 1 - sectionGap if rows < coverMinRows { return header } - return header + "\n" + c.coverArt.view(c.cover, c.width, rows) + return header + strings.Repeat("\n", 1+sectionGap) + c.coverArt.view(c.cover, c.width, rows) } // sectionHeader renders a list section label with a rule filling the rest diff --git a/internal/tui/layout.go b/internal/tui/layout.go new file mode 100644 index 00000000..e17a5a69 --- /dev/null +++ b/internal/tui/layout.go @@ -0,0 +1,101 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" +) + +// Layout names the amount of structure and space the TUI gives its content. +// Classic is the original edge-to-edge presentation. Spacious separates list rows +// and adds vertical breathing room, while leaving the terminal theme in charge of color. +type Layout string + +const ( + LayoutClassic Layout = "classic" + LayoutSpacious Layout = "spacious" +) + +// ParseLayout reads a layout name accepted by the tui command. +func ParseLayout(value string) (Layout, error) { + switch layout := Layout(strings.ToLower(strings.TrimSpace(value))); layout { + case LayoutClassic, LayoutSpacious: + return layout, nil + default: + return "", fmt.Errorf("layout must be classic or spacious (got %q)", value) + } +} + +func (l Layout) normalized() Layout { + if l == LayoutSpacious { + return l + } + return LayoutClassic +} + +func (l Layout) toggled() Layout { + if l.normalized() == LayoutSpacious { + return LayoutClassic + } + return LayoutSpacious +} + +// layoutMetrics are the cells spent around and between content. A small terminal +// keeps the selected layout but collapses its chrome until there is room again. +type layoutMetrics struct { + spacious bool + paddingX int + paddingY int + itemGap int +} + +func (l Layout) metrics(width, height int) layoutMetrics { + if l.normalized() != LayoutSpacious || width < 48 || height < 16 { + return layoutMetrics{} + } + return layoutMetrics{spacious: true, paddingY: 1, itemGap: 1} +} + +func (m layoutMetrics) horizontalChrome() int { + if !m.spacious { + return 0 + } + return 2 * m.paddingX +} + +func (m layoutMetrics) verticalChrome() int { + if !m.spacious { + return 0 + } + return 2 * m.paddingY +} + +func (m layoutMetrics) headerGap() int { + if m.spacious { + return 1 + } + return 0 +} + +func (m layoutMetrics) footerChrome() int { + if m.spacious { + return 0 + } + return 3 // two clear rows and the divider +} + +func (m layoutMetrics) drawFooterRule() bool { + return !m.spacious +} + +func (m layoutMetrics) render(content string, width, height int) string { + if !m.spacious { + return content + } + return lipgloss.NewStyle(). + Padding(m.paddingY, m.paddingX). + Width(width). + Height(height). + Render(content) +} diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go new file mode 100644 index 00000000..c72b1929 --- /dev/null +++ b/internal/tui/layout_test.go @@ -0,0 +1,99 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestParseLayout(t *testing.T) { + for input, want := range map[string]Layout{ + "classic": LayoutClassic, + "CLASSIC": LayoutClassic, + "spacious": LayoutSpacious, + " Spacious ": LayoutSpacious, + } { + got, err := ParseLayout(input) + if err != nil || got != want { + t.Errorf("ParseLayout(%q) = %q, %v; want %q", input, got, err, want) + } + } + if _, err := ParseLayout("roomy"); err == nil { + t.Error("ParseLayout accepted an unknown layout") + } +} + +func TestSpaciousLayoutAddsVerticalSpaceAtRoomySizes(t *testing.T) { + metrics := LayoutSpacious.metrics(80, 40) + if !metrics.spacious || metrics.paddingX != 0 || metrics.paddingY != 1 || metrics.itemGap != 1 { + t.Fatalf("spacious metrics = %+v", metrics) + } + if metrics.headerGap() != 1 || metrics.footerChrome() != 0 || metrics.drawFooterRule() { + t.Fatalf("spacious vertical spacing = header %d, footer %d, rule %t", metrics.headerGap(), metrics.footerChrome(), metrics.drawFooterRule()) + } + + rendered := metrics.render("Mail", 80, 20) + if width, height := lipgloss.Width(rendered), lipgloss.Height(rendered); width != 80 || height != 20 { + t.Errorf("spacious layout = %dx%d, want 80x20", width, height) + } + plain := stripANSI(rendered) + if strings.ContainsAny(plain, "┌┐└┘│─") { + t.Errorf("spacious layout drew a border:\n%s", plain) + } + if !strings.Contains(plain, "\nMail") { + t.Errorf("spacious layout lacks its vertical padding:\n%s", plain) + } +} + +func TestSpaciousLayoutCollapsesOnSmallTerminals(t *testing.T) { + for _, size := range [][2]int{{47, 40}, {80, 15}} { + metrics := LayoutSpacious.metrics(size[0], size[1]) + if metrics.spacious || metrics.itemGap != 0 { + t.Errorf("spacious metrics at %dx%d = %+v, want collapsed chrome", size[0], size[1], metrics) + } + } +} + +func TestControlGTogglesLayoutAndResizesTheActiveView(t *testing.T) { + m := modelWithBoxes() + classicWidth, classicHeight := m.vc.width, m.vc.height + + updated, cmd := m.Update(keyPress("ctrl+g")) + m = updated.(model) + if m.layout != LayoutSpacious || m.vc.layout.itemGap != 1 { + t.Fatalf("layout after ctrl+g = %q with metrics %+v", m.layout, m.vc.layout) + } + if m.vc.width != classicWidth || m.vc.height != classicHeight { + t.Errorf("spacious content = %dx%d, classic was %dx%d", m.vc.width, m.vc.height, classicWidth, classicHeight) + } + if cmd == nil { + t.Fatal("layout toggle did not announce the new layout") + } + + updated, _ = m.Update(keyPress("ctrl+g")) + m = updated.(model) + if m.layout != LayoutClassic || m.vc.width != classicWidth || m.vc.height != classicHeight { + t.Errorf("classic layout was not restored: layout=%q size=%dx%d", m.layout, m.vc.width, m.vc.height) + } +} + +func TestSpaciousMailRowsHaveNegativeSpace(t *testing.T) { + m := modelWithBoxes() + m.layout = LayoutSpacious + m.resizeActiveView() + view := stripANSI(m.mailView.View()) + if !strings.Contains(view, "\n\nPreviously Seen") { + t.Errorf("spacious mail rows have no blank row between them:\n%s", view) + } + lines := strings.Split(view, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "Previously Seen") { + if i+1 >= len(lines) || lines[i+1] != "" { + t.Errorf("spacious mail section has no blank row below its header:\n%s", view) + } + return + } + } + t.Errorf("spacious mail list lacks its Previously Seen section:\n%s", view) +} diff --git a/internal/tui/mail.go b/internal/tui/mail.go index ac9482a8..abd0ecae 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1514,9 +1514,13 @@ func (v *mailView) Resize(width, height int) { v.modal.resize(width, height) } v.postingList.setSize(width, height) + v.postingList.setItemGap(v.vc.layout.itemGap) v.searchList.setSize(width, height) + v.searchList.setItemGap(v.vc.layout.itemGap) v.bundleList.setSize(width, height) + v.bundleList.setItemGap(v.vc.layout.itemGap) v.seenList.setSize(width, height) + v.seenList.setItemGap(v.vc.layout.itemGap) v.topicViewport.SetWidth(width) v.contentHeight = height v.fitThreadViewport() diff --git a/internal/tui/section_view.go b/internal/tui/section_view.go index 5816b7a7..801993de 100644 --- a/internal/tui/section_view.go +++ b/internal/tui/section_view.go @@ -31,6 +31,7 @@ type viewContext struct { saveCover coverSaveFunc loadLastCalendar func() int64 saveLastCalendar func(id int64) error + layout layoutMetrics width int height int // content area height } diff --git a/internal/tui/toast.go b/internal/tui/toast.go index 24f50518..0867b6f2 100644 --- a/internal/tui/toast.go +++ b/internal/tui/toast.go @@ -67,9 +67,9 @@ func (m model) toastView() string { border, text = colorError, lipgloss.NewStyle().Foreground(colorError) } - // A toast is over the content, so it can never be wider than half the screen: the + // A toast is over the content, so it can never be wider than half the content: the // reader is looking at what they were doing, not at this. - body := truncateToWidth(terminal.SanitizeLine(m.toast.text), max(m.width/2-4, 10)) + body := truncateToWidth(terminal.SanitizeLine(m.toast.text), max(m.contentWidth()/2-4, 10)) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4cf5bf0a..e44f750a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -42,10 +42,11 @@ func (r OpenRequest) valid() bool { return r.AccountID >= 0 && ((r.Screener && r.TopicID == 0) || (!r.Screener && r.TopicID > 0)) } -// Options configures the TUI's initial destination. +// Options configures the TUI's initial destination and presentation. type Options struct { Open OpenRequest Instance string + Layout Layout } // --- Model --- @@ -57,6 +58,7 @@ type model struct { rootSDK *hey.Client cancel context.CancelFunc theme Theme + layout Layout styles styles help helpBar saveHelpHidden func(bool) error @@ -134,10 +136,14 @@ type model struct { } func newModel() model { - return newModelWithMailAccounts(nil, nil, "all", Watchers{}) + return newModelWithMailAccounts(nil, nil, "all") } -func newModelWithMailAccounts(rootSDK, sdk *hey.Client, selected string, watchers Watchers) model { +func newModelWithMailAccounts(rootSDK, sdk *hey.Client, selected string) model { + return newModelWithOptions(rootSDK, sdk, selected, Watchers{}, Options{}) +} + +func newModelWithOptions(rootSDK, sdk *hey.Client, selected string, watchers Watchers, options Options) model { theme := ResolveTheme() applyTheme(theme) s := newStyles() @@ -159,6 +165,7 @@ func newModelWithMailAccounts(rootSDK, sdk *hey.Client, selected string, watcher rootSDK: rootSDK, cancel: cancel, theme: theme, + layout: options.Layout.normalized(), styles: s, help: newHelpBar(s), section: sectionMail, @@ -312,11 +319,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.vc.width = msg.Width m.help.setWidth(msg.Width) - contentH := m.contentHeight() - m.vc.height = contentH - m.activeView.Resize(msg.Width, contentH) + m.resizeActiveView() m.updateHelpBindings() return m, nil @@ -597,7 +601,8 @@ func (m model) applyMailAccount(account mailAccountChoice, client *hey.Client) ( ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel stored, called on switch or quit m.cancel = cancel m.vc = newViewContext(ctx, m.rootSDK, client, m.styles) - m.vc.width = m.width + m.vc.layout = m.layout.metrics(m.width, m.height) + m.vc.width = m.contentWidth() m.vc.height = m.contentHeight() m.mailView = newMailView(m.vc) m.contactsView = newContactsView(m.vc) @@ -638,10 +643,10 @@ func (m model) contentView() string { case m.err != nil: // The section keeps its last good state underneath, so dismissing the error // with esc puts the reader back where they were. - box := errorView(terminal.SanitizeLine(m.err.Error()), m.width) - return overlayModal(m.activeView.View(), box, m.width, m.contentHeight()) + box := errorView(terminal.SanitizeLine(m.err.Error()), m.contentWidth()) + return overlayModal(m.activeView.View(), box, m.contentWidth(), m.contentHeight()) case m.loading: - return loadingView(m.width, m.contentHeight(), m.spinnerPhase) + return loadingView(m.contentWidth(), m.contentHeight(), m.spinnerPhase) default: return m.activeView.View() } @@ -649,9 +654,13 @@ func (m model) contentView() string { func (m model) View() tea.View { var b strings.Builder + metrics := m.layout.metrics(m.width, m.height) b.WriteString(renderHeader(&m)) b.WriteString("\n") + for range metrics.headerGap() { + b.WriteString("\n") + } if notice := m.mailWatchNotice(); notice != "" { b.WriteString(m.styles.title.Render(truncateStr(notice, max(m.width, 1)))) b.WriteString("\n") @@ -664,23 +673,27 @@ func (m model) View() tea.View { // The toast goes on last, over the modals too: it is the answer to what the reader // just did, and a form open over the list does not make it less so. if toast := m.toastView(); toast != "" { - x := max(m.width-lipgloss.Width(toast)-1, 0) - content = overlayAt(content, toast, x, 0, m.width, m.contentHeight()) + x := max(m.contentWidth()-lipgloss.Width(toast)-1, 0) + content = overlayAt(content, toast, x, 0, m.contentWidth(), m.contentHeight()) } + content = metrics.render(content, m.width, m.contentHeight()+metrics.verticalChrome()) b.WriteString(content) helpView := m.help.view() if helpView != "" { contentLines := strings.Count(b.String(), "\n") helpH := strings.Count(helpView, "\n") + 1 - footerH := 1 + helpH + footerH := metrics.footerChrome() + helpH padLines := m.height - contentLines - footerH - 1 for range max(padLines, 0) { b.WriteString("\n") } - b.WriteString(renderRule(m.width, "")) - b.WriteString("\n" + helpView) + if metrics.drawFooterRule() { + b.WriteString(renderRule(m.width, "")) + b.WriteString("\n") + } + b.WriteString(helpView) } v := tea.NewView(b.String()) @@ -760,26 +773,42 @@ func (m *model) updateHelpBindings() { bindings = append(bindings, helpBinding{"ctrl+a", description}) } } + if m.canToggleLayout() { + bindings = append(bindings, helpBinding{"ctrl+g", "toggle layout"}) + } m.help.setBindings(bindings) - contentHeight := m.contentHeight() - if contentHeight != m.vc.height { - m.vc.height = contentHeight - m.activeView.Resize(m.vc.width, contentHeight) + contentWidth, contentHeight := m.contentWidth(), m.contentHeight() + metrics := m.layout.metrics(m.width, m.height) + if contentWidth != m.vc.width || contentHeight != m.vc.height || metrics != m.vc.layout { + m.resizeActiveView() } } -// contentHeight gives the active view every row that is not navigation or a -// visible help footer. The footer carries two clear rows above its divider. +func (m *model) resizeActiveView() { + m.vc.layout = m.layout.metrics(m.width, m.height) + m.vc.width = m.contentWidth() + m.vc.height = m.contentHeight() + m.activeView.Resize(m.vc.width, m.vc.height) +} + +func (m model) contentWidth() int { + return max(m.width-m.layout.metrics(m.width, m.height).horizontalChrome(), 1) +} + +// contentHeight gives the active view every row that is not navigation, layout +// spacing, or a visible help footer. func (m model) contentHeight() int { + metrics := m.layout.metrics(m.width, m.height) footerHeight := 0 if helpHeight := m.help.height(); helpHeight > 0 { - footerHeight = helpHeight + 3 + footerHeight = helpHeight + metrics.footerChrome() } statusHeight := 0 if m.mailWatchNotice() != "" { statusHeight = 1 } - return max(m.height-headerHeight-footerHeight-statusHeight, 1) + chromeHeight := metrics.verticalChrome() + metrics.headerGap() + return max(m.height-headerHeight-footerHeight-statusHeight-chromeHeight, 1) } // --- Key handling --- @@ -833,6 +862,10 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, cmd } + if key == "ctrl+g" && m.canToggleLayout() { + return m.toggleLayout() + } + if key == "ctrl+s" && m.canOpenScreener() { return m.openScreener() } @@ -927,6 +960,25 @@ func (m model) canToggleHelp() bool { return !ok || !capturer.CapturingInput() } +func (m model) canToggleLayout() bool { + if m.mailAccountPicker || m.err != nil { + return false + } + capturer, ok := m.activeView.(inputCapturer) + return !ok || !capturer.CapturingInput() +} + +func (m model) toggleLayout() (tea.Model, tea.Cmd) { + m.layout = m.layout.toggled() + m.resizeActiveView() + m.updateHelpBindings() + label := "Classic" + if m.layout == LayoutSpacious { + label = "Spacious" + } + return m, notify(label + " layout") +} + func (m model) toggleHelp() (tea.Model, tea.Cmd) { hidden := !m.help.hidden m.help.setHidden(hidden) @@ -1272,7 +1324,7 @@ func (m model) handleSubnavKey(msg tea.KeyPressMsg) tea.Cmd { // interactive account switching, the live watchers, and an optional initial destination. func Run(rootSDK, sdk *hey.Client, selected string, watchers Watchers, options Options) error { calibrateWidths(os.Stdin, os.Stdout) - m := newModelWithMailAccounts(rootSDK, sdk, selected, watchers) + m := newModelWithOptions(rootSDK, sdk, selected, watchers, options) if options.Open.valid() { request := options.Open m.pendingOpen = &request diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c4a0dd43..faa9c7f3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -73,6 +73,8 @@ func keyPress(key string) tea.KeyPressMsg { switch key { case "ctrl+c": k = tea.Key{Code: 'c', Mod: tea.ModCtrl} + case "ctrl+g": + k = tea.Key{Code: 'g', Mod: tea.ModCtrl} case "esc": k = tea.Key{Code: tea.KeyEscape} case "enter":