diff --git a/docs/tui.md b/docs/tui.md index 41c32b70..0b3efb97 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -70,7 +70,7 @@ Sending a reply or a forward from a thread closes the thread and returns you to While reading a thread, the From header shows the actual send-as address when HEY records one separately from the account user. -While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the complete selected destination without moving the thread. Press Enter to open it; opening stays unavailable until the terminal is wide enough to show the complete destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available. +While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the selected destination without moving the thread. A destination too long for one row wraps onto as many rows as it needs, so you always see all of it. Press Enter to open it; opening stays unavailable until the terminal is tall enough to show the whole destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available. Most of those keep working while you are reading a thread, the way the web app's topic toolbar stays live: `r`, `f`, `v`, `b`, `u`, `i`, `l`, `a`, `d`, `p` and `t` all act on diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 82b02e19..4609c5b7 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -9,6 +9,7 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -1752,8 +1753,9 @@ func (v *mailView) threadNotices() []string { } // LinkFooter reserves one footer row for a thread with selectable links. Its text -// stays blank until a link is selected, so moving through links never changes the -// viewport's height. A destination can open only when the footer shows it in full. +// stays blank until a link is selected, and a destination that fits keeps to that +// row, so moving between such links never changes the viewport's height. A longer +// one wraps onto the rows it needs to be shown in full. func (v *mailView) LinkFooter() (text string, visible bool) { if !v.inThread || v.modal != nil || len(v.links) == 0 { return "", false @@ -1771,11 +1773,20 @@ func (v *mailView) linkDestinationFooter() (string, bool) { return "", false } destination := terminal.SanitizeLine(v.links[v.selectedLink].destination) - footer := "Open: " + destination + " (press Enter to visit)" + const suffix = " (press Enter to visit)" + footer := "Open: " + destination + suffix if lipgloss.Width(footer) <= width { return footer, true } - return truncateToWidth("Enlarge the terminal to inspect this link", width), false + // A destination too long for one row wraps onto as many as it needs, so the + // reader sees all of it before it opens. The model decides whether they fit. + lines := strings.Split(ansi.Hardwrap("Open: "+destination, width, true), "\n") + if last := len(lines) - 1; lipgloss.Width(lines[last]+suffix) <= width { + lines[last] += suffix + } else { + lines = append(lines, truncateToWidth(strings.TrimSpace(suffix), width)) + } + return strings.Join(lines, "\n"), true } func (v *mailView) linkDestinationReviewable() bool { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 0cf72ef1..3a2aa31c 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -482,7 +482,7 @@ func TestMailViewKeepsAPartialThreadsNoticeAndLeavesItUnseen(t *testing.T) { } } -func TestLinkDestinationMustFitFooterBeforeOpening(t *testing.T) { +func TestLinkFooterShowsTheWholeDestinationBeforeOpening(t *testing.T) { v := newMailView(testVC()) v.inThread = true v.topicID = 100 @@ -530,20 +530,16 @@ func TestLinkDestinationMustFitFooterBeforeOpening(t *testing.T) { v.vc.width = 24 footer, visible = v.LinkFooter() - if !visible || v.linkDestinationReviewable() || strings.Contains(footer, destination) { - t.Errorf("narrow link footer = %q visible=%v reviewable=%v", footer, visible, v.linkDestinationReviewable()) + if !visible || !v.linkDestinationReviewable() || !strings.Contains(strings.ReplaceAll(footer, "\n", ""), destination) { + t.Errorf("narrow link footer = %q visible=%v reviewable=%v, want the whole destination wrapped", footer, visible, v.linkDestinationReviewable()) } - opened = "" - if cmd, handled := v.handleLinkKey(keyPress("enter")); !handled || cmd != nil || opened != "" { - t.Errorf("hidden destination opened: handled=%v command=%v destination=%q", handled, cmd != nil, opened) - } - - v.vc.width = 3 - if v.linkDestinationReviewable() { - t.Error("destination wider than a tiny terminal was marked reviewable") + for _, line := range strings.Split(footer, "\n") { + if lipgloss.Width(line) > v.vc.width { + t.Errorf("wrapped footer line %q is wider than %d", line, v.vc.width) + } } - if cmd, handled := v.handleLinkKey(keyPress("enter")); !handled || cmd != nil { - t.Errorf("tiny terminal opened destination: handled=%v command=%v", handled, cmd != nil) + if !strings.HasSuffix(footer, "(press Enter to visit)") { + t.Errorf("wrapped footer = %q, want it to end with the action", footer) } v.selectedLink = -1 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9527344d..1e293344 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -688,7 +688,7 @@ func (m model) View() tea.View { } footerH := 1 + helpH if linkFooterVisible { - footerH++ + footerH += 1 + strings.Count(linkFooter, "\n") } padLines := m.height - contentLines - footerH - 1 for range max(padLines, 0) { @@ -797,7 +797,18 @@ func (m *model) updateHelpBindings() { } } +// linkFooter is the link status as it goes on screen. A destination that wraps onto +// more rows than the terminal can spare is replaced by a hint, and cannot be opened +// until the terminal is enlarged. func (m model) linkFooter() (string, bool) { + text, visible := m.activeLinkFooter() + if visible && strings.Contains(text, "\n") && !m.linkFooterFits() { + return truncateToWidth("Enlarge the terminal to inspect this link", max(m.width, 0)), true + } + return text, visible +} + +func (m model) activeLinkFooter() (string, bool) { if provider, ok := m.activeView.(linkFooterProvider); ok { return provider.LinkFooter() } @@ -805,7 +816,7 @@ func (m model) linkFooter() (string, bool) { } func (m model) linkFooterFits() bool { - _, visible := m.linkFooter() + text, visible := m.activeLinkFooter() if !visible { return false } @@ -813,18 +824,19 @@ func (m model) linkFooterFits() bool { if m.mailWatchNotice() != "" { statusHeight = 1 } - return m.height >= headerHeight+m.help.height()+3+statusHeight+1 + return m.height >= headerHeight+m.help.height()+3+statusHeight+1+strings.Count(text, "\n") } // contentHeight gives the active view every row that is not navigation or a // visible footer. Ordinary help has two clear rows above its divider; a link -// footer uses one of them for its stable status row. +// footer uses one of them for its status row, and takes any further rows a +// wrapped destination needs from the content. func (m model) contentHeight() int { footerHeight := 0 helpHeight := m.help.height() - _, linkFooterVisible := m.linkFooter() + linkFooter, linkFooterVisible := m.linkFooter() if helpHeight > 0 || linkFooterVisible { - footerHeight = helpHeight + 3 + footerHeight = helpHeight + 3 + strings.Count(linkFooter, "\n") } statusHeight := 0 if m.mailWatchNotice() != "" { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index bf0ab5c2..6d5c77ee 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1757,6 +1757,65 @@ func TestQLeavesAThreadWithASelectedLink(t *testing.T) { } } +func TestRootModelWrapsALongDestinationOntoTheRowsItNeeds(t *testing.T) { + m := openLinkThreadThroughModel(t) + var opened []string + m.mailView.vc.openURL = func(destination string) error { + opened = append(opened, destination) + return nil + } + updated, _ := m.Update(tea.WindowSizeMsg{Width: 40, Height: 30}) + m = updated.(model) + unselectedViewportHeight := m.mailView.topicViewport.Height() + for range 2 { + updated, _ = m.Update(keyPress("tab")) + m = updated.(model) + } + if m.mailView.selectedLink != 1 { + t.Fatalf("selected link = %d, want second occurrence", m.mailView.selectedLink) + } + + screen := stripANSI(m.View().Content) + for _, line := range []string{"Open: https://example.org/second?full=de", "stination (press Enter to visit)"} { + if !strings.Contains(screen, line) { + t.Errorf("wrapped footer row %q is not on screen: %q", line, screen) + } + } + if got := strings.Count(m.View().Content, "\n") + 1; got > m.height { + t.Errorf("screen draws %d rows, want at most %d", got, m.height) + } + if got := m.mailView.topicViewport.Height(); got != unselectedViewportHeight-1 { + t.Errorf("viewport height = %d, want %d to make room for the second footer row", got, unselectedViewportHeight-1) + } + selected := m.mailView.links[m.mailView.selectedLink] + visibleStart := m.mailView.topicViewport.YOffset() + visibleEnd := visibleStart + m.mailView.topicViewport.Height() - 1 + if selected.startLine < visibleStart || selected.endLine > visibleEnd { + t.Errorf("selected range %d-%d is outside viewport %d-%d", selected.startLine, selected.endLine, visibleStart, visibleEnd) + } + + updated, cmd := m.Update(keyPress("enter")) + m = updated.(model) + if cmd == nil { + t.Fatal("enter did not open a destination shown in full over two rows") + } + runCmd(cmd) + if len(opened) != 1 || opened[0] != "https://example.org/second?full=destination" { + t.Fatalf("opened destinations = %q, want the exact second destination", opened) + } + + // One row short of what the wrapped destination needs: a hint instead, and no opening. + updated, _ = m.Update(tea.WindowSizeMsg{Width: 40, Height: headerHeight + m.help.height() + 4}) + m = updated.(model) + screen = stripANSI(m.View().Content) + if !strings.Contains(screen, "Enlarge the terminal") || strings.Contains(screen, "Open: https://example.org/second") { + t.Errorf("short terminal footer = %q, want the enlarge hint instead of a partial destination", screen) + } + if _, cmd = m.Update(keyPress("enter")); cmd != nil || len(opened) != 1 { + t.Errorf("enter opened a destination the footer could not show: command=%v opened=%q", cmd != nil, opened) + } +} + func TestRootModelNavigatesThreadLinksAndOpensExactDestination(t *testing.T) { m := openLinkThreadThroughModel(t) var opened []string