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
8 changes: 8 additions & 0 deletions docs/changes/unreleased/1496-tab-title-under-its-tab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
kind: changed
title: A hovered tab's full title sits on the blank row and ends under its tab
pr: 1496
surface: [chat, docs]
invalidates:
- Hovering a tab drew its full title from the left margin on the row right under the tab strip, over the rule; it now sits on the head's blank row below the rule, right-aligned to the hovered tab's right edge (close mark included), sticking out to the left when wider than the tab and reaching right only when it cannot fit left of that edge.
---
5 changes: 4 additions & 1 deletion internal/manual/chat/screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,10 @@ title does not wait for the answer to finish.
their available width; there is no separately generated tab label. The title also
reaches an idle chat or a chat you have switched away from.

Hover over a tab to reveal its full title beneath it. Long titles wrap; the tab and
Hover over a tab to reveal its full title beneath it, on the blank row under the line
that closes the top of the screen. The title ends where the tab ends, and a title wider
than the tab reaches out to the left; only one too wide for that room reaches right, and
one wider than the whole row wraps over the top of the conversation. The tab and
conversation stay in place, and the preview disappears when the pointer leaves.

**Temporary failures and unusable names retry automatically.** Naming asks again, with
Expand Down
36 changes: 31 additions & 5 deletions internal/tui3/tabtitle.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import (
"strings"
)

// tabTitlePreview reveals the full name beneath the hovered label. It overlays
// existing rows so neither the tab targets nor the transcript scroll position
// move. Reading text stays still, including in the reduced-motion tier.
// tabTitlePreview reveals the full name beneath the hovered label, on the head's
// blank row under the rule (head.go) so the rule that seals the head stays drawn.
// It overlays existing rows so neither the tab targets nor the transcript scroll
// position move. Reading text stays still, including in the reduced-motion tier.
//
// THE NAME ENDS WHERE ITS TAB ENDS. It is right-aligned to the tab's right edge,
// close cells included, and a name wider than the tab sticks out to the left, so
// the eye reads it as belonging to the tab above rather than to the conversation
// below. Only a name that cannot fit between the left margin and that edge takes
// cells to the right of it, and only as many as it needs; one wider than the
// whole row wraps onto the rows below, over the top of the conversation.
func (a *app) tabTitlePreview(frame string) string {
hit, ok := a.hotTab()
if !ok || (hit.kind != tabHere && hit.kind != tabOther) || hit.tab.start {
Expand All @@ -28,12 +36,30 @@ func (a *app) tabTitlePreview(frame string) string {
return frame
}
preview := strings.Split(ansi.Wrap(title, width-2*headLabelAt, ""), "\n")
block := 0
for _, line := range preview {
block = max(block, ansi.StringWidth(line))
}
end := min(width, max(a.tabBoxEnd(hit), headLabelAt+block))
start := end - block
for i, line := range preview {
at := placeTabRow + 1 + i
at := placeHeadRows - 1 + i
if at >= len(rows) {
break
}
rows[at] = a.pal.ink(strings.Repeat(" ", headLabelAt) + line + strings.Repeat(" ", max(0, width-headLabelAt-ansi.StringWidth(line))))
rows[at] = a.pal.ink(strings.Repeat(" ", start) + line + strings.Repeat(" ", max(0, width-start-ansi.StringWidth(line))))
}
return strings.Join(rows, "\n")
}

// tabBoxEnd is the column just past a tab's filled box: its close cells when the
// strip drew them beside the label, which it does on every tab, and the label's
// own edge otherwise.
func (a *app) tabBoxEnd(hit tabHit) int {
for _, other := range a.chatTabHits {
if other.kind == tabClose && other.span.from == hit.span.to {
return other.span.to
}
}
return hit.span.to
}
76 changes: 73 additions & 3 deletions internal/tui3/tabtitle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ func TestTabHoverRevealsFullTitleWithoutMovingTargets(t *testing.T) {
frame := strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1)
rows := strings.Split(frame, "\n")
rows[placeTabRow] = before
seal := rule(a.width)
rows[placeTabRow+1] = seal
frame = strings.Join(rows, "\n")
shown := a.tabTitlePreview(frame)
if !strings.Contains(plain(shown), a.title) {
t.Fatal("hover lost the full title")
shown := strings.Split(a.tabTitlePreview(frame), "\n")
if !strings.Contains(plain(shown[placeHeadRows-1]), a.title) {
t.Fatal("hover did not put the full title on the head's blank row")
}
if shown[placeTabRow+1] != seal {
t.Fatal("preview covered the rule under the strip")
}
if a.tabsRow(a.width) != before || tabSpanFor(t, a, a.title) != span {
t.Fatal("preview moved the tab")
Expand All @@ -32,3 +37,68 @@ func TestTabHoverRevealsFullTitleWithoutMovingTargets(t *testing.T) {
t.Fatal("preview remained after pointer left")
}
}

// THE NAME ENDS WHERE ITS TAB ENDS: a name wider than the tab sticks out to the
// left, and only a name too wide for the room left of that edge takes cells to
// its right, and then only as many as it needs.
func TestTabTitlePreviewEndsUnderItsTab(t *testing.T) {
a, _, _ := tabApp(t)
// Every title below opens with the same words, so the tab's cut label, and
// with it the tab's place on the strip, is the same for all of them.
base := "Shipping the parser with complete unicode support"
a.title = base
a.tabsRow(a.width)
span := tabSpanFor(t, a, a.title)
a.hot, _ = a.tabHoverAt(span.from, placeTabRow)
hit, ok := a.hotTab()
if !ok {
t.Fatal("the pointer is not over the tab")
}
end := a.tabBoxEnd(hit)
if end != span.to+tabCloseCells {
t.Fatalf("the tab's box ends at %d, want its close cells' edge %d", end, span.to+tabCloseCells)
}
preview := func(title string) []string {
t.Helper()
a.title = title
row := a.tabsRow(a.width)
if got := tabSpanFor(t, a, title); got != span {
t.Fatalf("a longer title moved the tab from %+v to %+v", span, got)
}
rows := strings.Split(strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1), "\n")
rows[placeTabRow] = row
shown := strings.Split(a.tabTitlePreview(strings.Join(rows, "\n")), "\n")
for i := range shown {
shown[i] = plain(shown[i])
}
return shown
}
grown := func(width int) string {
title := base
for len(title) < width {
title += " and more"
}
return strings.TrimSpace(title[:width])
}

if end-headLabelAt <= len(base) || end > a.width-headLabelAt {
t.Fatalf("the fixture's tab ends at %d, which cannot show both a short and a long name", end)
}
shown := preview(base)
if at := strings.Index(shown[placeHeadRows-1], base); at+len(base) != end {
t.Fatalf("a name that fits ends at %d, want the tab's edge %d:\n%q", at+len(base), end, shown[placeHeadRows-1])
}

long := grown(end - headLabelAt + 10)
shown = preview(long)
if at := strings.Index(shown[placeHeadRows-1], long); at != headLabelAt {
t.Fatalf("a name wider than the room left of its tab starts at %d, want the margin %d:\n%q", at, headLabelAt, shown[placeHeadRows-1])
}

wide := grown(a.width + 20)
shown = preview(wide)
first, second := shown[placeHeadRows-1], shown[placeHeadRows]
if !strings.HasPrefix(first, strings.Repeat(" ", headLabelAt)+"Shipping") || strings.TrimSpace(second) == "" || !strings.HasPrefix(second, strings.Repeat(" ", headLabelAt)) || second[headLabelAt] == ' ' {
t.Fatalf("a name wider than the row does not wrap from the margin:\n%q\n%q", first, second)
}
}
Loading