Skip to content

Commit c052f78

Browse files
author
gluck
committed
cli: the cursor clears the header, and the band stops at the corner
THE HEADER COVERS ROWS. The sticky question stands ON the first rows of the viewport rather than pushing them down, but the scroll floor was the offset, so a selection scrolled "into view" landed underneath it: ^P up a delta list moved the cursor and showed nothing, twice in a row, which is indistinguishable from a cursor that has stopped. The floor is the offset plus the header's height now. One pass, not a fixed point: scrolling up can retire the header, and the row is only ever further from it for that. AND A CORNER IS STILL A RULE. The band that carries the selection wash stops at the rule closing a box, tested by the glyph in the first painted cell -- but the adornment's snake closes into `╰`, so the band walked straight past it and washed the `< figaro` header of the answer below. A row is a rule when the line runs from its first cell or its second.
1 parent 6306b10 commit c052f78

4 files changed

Lines changed: 111 additions & 6 deletions

File tree

‎internal/cli/adornment_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,84 @@ func TestSelectedBlockKeepsItsGutterMarker(t *testing.T) {
606606
}
607607
t.Fatal("fixture: no short row to wash")
608608
}
609+
610+
// stickyAdorned is the fixture for what the header hides: three turns, the
611+
// last one's question adorned and open, and the sticky header on.
612+
func stickyAdorned(t testing.TB) (*transcript, nodeRef) {
613+
t.Helper()
614+
tr := adornFixtureH(t, 8, 3)
615+
tr.view.(*ariaView).settings.sticky = true
616+
inq := nodeRef{turn: 3, index: inquiryNode}
617+
tr.adorned[inq] = true
618+
tr.dropTurnsRows(map[int]struct{}{3: {}})
619+
tr.buildIndex()
620+
return tr, inq
621+
}
622+
623+
// THE HEADER COVERS ROWS, SO THE CURSOR MUST CLEAR IT. Walking a delta list
624+
// upward with ^P used to park the focused row underneath the pinned question,
625+
// where it cannot be seen: the scroll floor was the offset, and the offset is
626+
// not where the conversation starts on screen.
627+
func TestSelectedRowClearsTheStickyHeader(t *testing.T) {
628+
tr, inq := stickyAdorned(t)
629+
tr.selectRef(deltaRefOf(inq, 2), false)
630+
631+
// Park the selection under the header on purpose, then ask for it back.
632+
span, ok := tr.nodeSpanOf(tr.selection.focus.nodeRef)
633+
if !ok {
634+
t.Fatal("fixture: the delta row has no span")
635+
}
636+
tr.offset = span.first
637+
tr.render()
638+
if tr.headRows() == 0 {
639+
t.Fatal("fixture: the header is not pinned, so nothing is covered")
640+
}
641+
tr.ensureSelectionVisible()
642+
if head := tr.headRows(); span.first < tr.offset+head {
643+
t.Fatalf("the focused row is under the header: row %d, offset %d, header %d",
644+
span.first, tr.offset, head)
645+
}
646+
647+
// And every step up the list stays clear of it.
648+
for range 2 {
649+
tr.key(0x10) // ^P
650+
span, ok := tr.nodeSpanOf(tr.selection.focus.nodeRef)
651+
if !ok {
652+
t.Fatal("the focus lost its span")
653+
}
654+
if head := tr.headRows(); span.first < tr.offset+head {
655+
t.Fatalf("^P parked %+v under the header: row %d, offset %d, header %d",
656+
tr.selection.focus.nodeRef, span.first, tr.offset, head)
657+
}
658+
}
659+
}
660+
661+
// THE BAND STOPS AT THE CORNER THE SNAKE CLOSES INTO. That row is a rule
662+
// wearing a corner glyph, so a band that only knew `─` ran straight past it
663+
// and washed the voice header of the answer below.
664+
func TestWashStopsAtTheAdornmentsCorner(t *testing.T) {
665+
defer term.SetColorMode(term.ColorAlways)()
666+
tr, inq := stickyAdorned(t)
667+
tr.selectRef(deltaRefOf(inq, 2), false)
668+
tr.ensureSelectionVisible()
669+
670+
rows := tr.window(tr.offset, tr.offset+8, nil)
671+
corner, voice := -1, -1
672+
for i, r := range rows {
673+
switch plain := strings.TrimSpace(stripANSI(r)); {
674+
case strings.HasPrefix(plain, "╰─"):
675+
corner = i
676+
case plain == "< figaro":
677+
voice = i
678+
}
679+
}
680+
if corner < 0 || voice < 0 {
681+
t.Fatalf("fixture: want a corner and a voice header:\n%s", strings.Join(stripANSIAll(rows), "\n"))
682+
}
683+
if !washed(rows[corner]) {
684+
t.Errorf("the corner closes the box and belongs to the band: %q", stripANSI(rows[corner]))
685+
}
686+
if washed(rows[voice]) {
687+
t.Errorf("the wash ran past the corner onto %q", stripANSI(rows[voice]))
688+
}
689+
}

‎internal/cli/transcript_index.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"strings"
5+
"unicode/utf8"
56

67
"github.com/jack-work/figaro/internal/livelog/aria"
78
ldrender "github.com/jack-work/figaro/internal/livelog/render"
@@ -342,11 +343,17 @@ func (t *transcript) bandEdge(from, step int) int {
342343
return edge
343344
}
344345

345-
// isRuleRow reports whether a row is one of the box's horizontal rules: the
346-
// only rows whose first painted cell is the rule glyph.
346+
// isRuleRow reports whether a row is one of the box's horizontal rules, which
347+
// is where a band stops. A rule may open on a corner (the adornment's snake
348+
// closes into one) so the glyph at column zero is not the test: the row is a
349+
// rule when the line runs from its first cell or its second.
347350
func isRuleRow(row string) bool {
348351
rest, _ := firstVisible(row)
349-
return strings.HasPrefix(rest, "─")
352+
if strings.HasPrefix(rest, "─") {
353+
return true
354+
}
355+
_, n := utf8.DecodeRuneInString(rest)
356+
return n > 0 && strings.HasPrefix(rest[n:], "─")
350357
}
351358

352359
// washVisual paints the visual highlight over a finished row. It is applied

‎internal/cli/transcript_selection.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,12 @@ func (t *transcript) repairSelection() {
804804

805805
// ensureSelectionVisible scrolls the focused node into the body, if it is not
806806
// already there.
807+
//
808+
// THE HEADER COVERS ROWS, so "in the body" is not "past the offset". The
809+
// sticky question stands ON the first rows of the viewport rather than pushing
810+
// them down, and a selection scrolled to the offset landed UNDER it: walking
811+
// a delta list upward with ^P looked like a cursor that stopped moving. The
812+
// floor is the offset plus whatever the header hides.
807813
func (t *transcript) ensureSelectionVisible() {
808814
if !t.selection.active {
809815
return
@@ -814,8 +820,11 @@ func (t *transcript) ensureSelectionVisible() {
814820
return
815821
}
816822
body, _ := t.layout(len(t.footLines()))
817-
if span.first < t.offset {
818-
t.offset = span.first
823+
if head := t.headRows(); span.first < t.offset+head {
824+
// One pass, not a fixed point: scrolling up may retire the header
825+
// (the question's own rows reach the top), and the row is only ever
826+
// further from the chrome for it.
827+
t.offset = max(span.first-head, 0)
819828
} else if span.last >= t.offset+body {
820829
t.offset = span.last - body + 1
821830
}

‎skills/figaro/reference/ui-stream.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,15 @@ owns, its chrome rows included, so the band a reader sees IS the block.
612612
The wash also closes over the chrome BETWEEN washed rows and over the rule that
613613
fences the box beneath them: select a question and the heading, the text and
614614
the seam under it lift together. It stops at the first row of a block that is
615-
not selected, so the answer's voice header stays the answer's.
615+
not selected, and at the rule that closes the box, corner glyph and all (the
616+
adornment's snake closes into a `╰`, and a band that only knew `─` washed the
617+
answer's voice header on the way past).
618+
619+
**A selected row is never under the header.** The pinned question COVERS the
620+
first rows of the viewport rather than pushing them down, so the floor a
621+
selection scrolls against is the offset plus the header's height. Walking a
622+
delta list upward with `^P` pushes the conversation down a row at a time
623+
instead of parking the cursor somewhere the reader cannot see it.
616624

617625
Colour is the only channel here. A terminal with colour off shows no selection
618626
cue at all, which is the deliberate end of a cue that used to be drawn twice.

0 commit comments

Comments
 (0)