Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions internal/tui/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
22 changes: 9 additions & 13 deletions internal/tui/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -797,34 +797,46 @@ 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()
}
return "", false
}

func (m model) linkFooterFits() bool {
_, visible := m.linkFooter()
text, visible := m.activeLinkFooter()
if !visible {
return false
}
statusHeight := 0
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() != "" {
Expand Down
59 changes: 59 additions & 0 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading