Skip to content

Commit 6c1f2c5

Browse files
author
gluck
committed
cli: the selection is a wash, and it covers the whole block
THE BAR IS GONE. A selected block wore a slim cyan bar in the left margin AND a background lift; the bar said what the lift already said, in the column the adornment's snake wants, and it was a second thing to keep right at every width. The lift is the whole cue now, one step off the pane for a block in range and one step further for the focused one, which is what tells a reader where Enter, t and d will land. AND IT COVERS THE BLOCK, not the half of it that is text. Chrome rows carrying a block's ref are washed with it, the chrome BETWEEN washed rows is washed, and the band closes over the rule that fences the box beneath them: select a question and its heading, its text and the seam under it lift together, which is what Gluck asked for and what a "selection" ought to mean. It stops at the first row of a block that is not selected, so the answer's voice header stays the answer's. The wash moves into the palette (term.NodeWash) beside the visual selection's, rather than living as a literal in the pager, and firstVisible is now one scanner for "the row from its first painted cell", shared by the gutter junctions and the band's rule test. WITH COLOUR OFF THERE IS NO CUE. Deliberate: nothing else in the pager is legible without colour either, and reverse video is a one-line change in one place if anyone ever needs it. TESTS. Two traps, both of which made assertions vacuous: - term.NodeWash answers "" when colour is off, and a test process is not a terminal, so `strings.Contains(row, term.NodeWash(...))` was TRUE FOR EVERY ROW. The washes are now read once with colour forced. - `tmux capture-pane -e` emits renditions as a RUNNING STATE across the whole capture, not per row: two washed rows share one sequence and the second, read alone, looks unwashed. That cost half an hour of hunting a paint bug that was not there (the emitted frame was correct, verified with pipe-pane). Per-row colour claims against a capture now go through capturedWashes, which tracks the background in force at each row's first cell. The colour frame goldens were already stale on main (they only rebuild under FORCE_COLOR); they are regenerated here along with the plain pair.
1 parent 368db97 commit 6c1f2c5

27 files changed

Lines changed: 463 additions & 351 deletions

โ€Žinternal/cli/adornment_pty_test.goโ€Ž

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/jack-work/figaro/api/rpc"
1616
ldrender "github.com/jack-work/figaro/internal/livelog/render"
1717
"github.com/jack-work/figaro/internal/tape"
18+
"github.com/jack-work/figaro/internal/term"
1819
)
1920

2021
// THE ADORNMENT, IN A REAL TERMINAL.
@@ -97,9 +98,9 @@ func adornSelectedMarker(t *testing.T, p *adornPane) {
9798
// the last block, so prose is one step BACK from it.
9899
p.key("Enter") // close what the previous case opened
99100
p.key("C-p")
100-
rows, styled := p.rows(), p.styled()
101+
rows, wash := p.rows(), p.paneWashed()
101102
i := p.only(t, rows, adornProseTail)
102-
if !washed(styled[i]) {
103+
if !wash[i] {
103104
t.Fatalf("the prose block is not the selection:\n%s", p.dump())
104105
}
105106
if w := ldrender.Width(rows[i]); w != p.w || !strings.HasSuffix(rows[i], deltaGlyph) {
@@ -173,17 +174,17 @@ func adornInquiryOpens(t *testing.T, p *adornPane) {
173174
t.Errorf("the rule under the list does not close on %q: %q", snakeClose, clean(rows[last+1]))
174175
}
175176

176-
// The wash. The question's own text is selected and washed; the
177-
// adornment's chrome row is not, and the head stays visible on it.
178-
styled := p.styled()
179-
if !washed(styled[p.only(t, styled, adornAsk)]) {
177+
// The wash covers the whole selected block, chrome included, and the
178+
// parked head stays visible on it.
179+
wash := p.paneWashed()
180+
if !wash[p.only(t, rows, adornAsk)] {
180181
t.Errorf("the selected question is not washed\n%s", p.dump())
181182
}
182-
if washed(styled[anchor]) {
183-
t.Errorf("the selection wash swallowed the adornment's chrome row: %q", clean(styled[anchor]))
183+
if !wash[anchor] {
184+
t.Errorf("the block's chrome row is not washed with the rest of it\n%s", p.dump())
184185
}
185-
if !strings.Contains(clean(styled[anchor]), deltaGlyph) {
186-
t.Errorf("the parked head is gone from the chrome row: %q", clean(styled[anchor]))
186+
if !strings.Contains(rows[anchor], deltaGlyph) {
187+
t.Errorf("the parked head is gone from the chrome row: %q", rows[anchor])
187188
}
188189
}
189190

@@ -198,8 +199,8 @@ func adornRowsWalk(t *testing.T, p *adornPane) {
198199

199200
// Out of the list, onto the block below it, then back in from below.
200201
p.key("C-n")
201-
styled := p.styled()
202-
if !washed(styled[p.only(t, styled, adornProseHead)]) {
202+
wash := p.paneWashed()
203+
if !wash[p.only(t, p.rows(), adornProseHead)] {
203204
t.Fatalf("^N past the last row did not select the block below\n%s", p.dump())
204205
}
205206
p.key("C-p")
@@ -209,13 +210,13 @@ func adornRowsWalk(t *testing.T, p *adornPane) {
209210
p.key("C-p")
210211
adornFocus(t, p, adornAskKeys[0], "stepping up inside the list")
211212
p.key("C-p")
212-
styled = p.styled()
213-
if !washed(styled[p.only(t, styled, adornAsk)]) {
213+
wash, rows := p.paneWashed(), p.rows()
214+
if !wash[p.only(t, rows, adornAsk)] {
214215
t.Errorf("^P past the first row did not select the parent block\n%s", p.dump())
215216
}
216217
for _, key := range adornAskKeys {
217-
if row := styled[p.only(t, styled, key)]; washed(row) {
218-
t.Errorf("the parent's wash reached delta row %q: %q", key, clean(row))
218+
if i := p.only(t, rows, key); wash[i] {
219+
t.Errorf("the parent's wash reached delta row %q: %q", key, clean(rows[i]))
219220
}
220221
}
221222
}
@@ -232,8 +233,7 @@ func adornCloseLands(t *testing.T, p *adornPane) {
232233
t.Errorf("Enter left the row for %q on screen\n%s", key, p.dump())
233234
}
234235
}
235-
styled := p.styled()
236-
if !washed(styled[p.only(t, styled, adornAsk)]) {
236+
if !p.paneWashed()[p.only(t, rows, adornAsk)] {
237237
t.Errorf("after closing, the question is not the selection\n%s", p.dump())
238238
}
239239
}
@@ -267,10 +267,10 @@ func adornToolSnake(t *testing.T, p *adornPane) {
267267
adornFocus(t, p, adornToolKeys[0], "the tool's first row")
268268

269269
// The head is drawn INTO the washed row, and the wash does not eat it.
270-
styled := p.styled()
271-
row := styled[p.only(t, styled, adornToolKeys[0])]
272-
if !washed(row) || !strings.Contains(row, deltaGlyph) {
273-
t.Errorf("the focused row lost its head or its wash: %q", clean(row))
270+
rows = p.rows()
271+
i := p.only(t, rows, adornToolKeys[0])
272+
if !p.paneWashed()[i] || !strings.Contains(rows[i], deltaGlyph) {
273+
t.Errorf("the focused row lost its head or its wash: %q", rows[i])
274274
}
275275

276276
p.key("C-p") // back to the block
@@ -307,18 +307,18 @@ func adornBodyAndList(t *testing.T, p *adornPane) {
307307
// the snake's head in its spine column, and alone in both.
308308
func adornFocus(t *testing.T, p *adornPane, key, what string) {
309309
t.Helper()
310-
styled, plain := p.styled(), p.rows()
311-
i := p.only(t, styled, key)
312-
if !washed(styled[i]) {
310+
wash, plain := p.paneWashed(), p.rows()
311+
i := p.only(t, plain, key)
312+
if !wash[i] {
313313
t.Errorf("%s: the row for %q is not the focus\n%s", what, key, p.dump())
314314
return
315315
}
316316
if !strings.Contains(plain[i], deltaGlyph) {
317317
t.Errorf("%s: the row for %q carries no head: %q", what, key, plain[i])
318318
}
319-
for j, row := range styled {
320-
if j != i && washed(row) && strings.Contains(clean(row), "->") {
321-
t.Errorf("%s: row %d is a washed delta row too: %q", what, j, clean(row))
319+
for j, row := range plain {
320+
if j != i && wash[j] && strings.Contains(row, "->") {
321+
t.Errorf("%s: row %d is a washed delta row too: %q", what, j, row)
322322
}
323323
}
324324
}
@@ -532,8 +532,83 @@ func countGlyph(rows []string, glyph string) int {
532532
return n
533533
}
534534

535-
// washed reports the selection lift, by the very constant the pager paints.
536-
func washed(row string) bool { return strings.Contains(row, selBg) }
535+
// nodeWashes are the two selection washes as the palette spells them, read
536+
// once with colour forced: a test process is not a terminal, so asking term
537+
// in the moment would answer "" and match every row ever painted.
538+
var nodeWashes = func() []string {
539+
defer term.SetColorMode(term.ColorAlways)()
540+
return []string{term.NodeWash(false), term.NodeWash(true)}
541+
}()
542+
543+
// washed reports the selection lift on a row the PAINTER produced, which
544+
// carries its own renditions from column zero.
545+
func washed(row string) bool {
546+
return strings.Contains(row, nodeWashes[0]) || strings.Contains(row, nodeWashes[1])
547+
}
548+
549+
// A TMUX CAPTURE IS A RUNNING STATE, NOT A ROW AT A TIME RENDERING.
550+
// `capture-pane -e` emits an escape only where the attributes CHANGE, and it
551+
// carries them ACROSS line boundaries: two washed rows in a row share one
552+
// sequence, and the second, read on its own, looks unwashed. That is how a
553+
// correct frame was read as a bug for half an hour. Every per-row colour claim
554+
// against a capture goes through paneWashed, which tracks the background in
555+
// force at each row's first cell from the top of the capture.
556+
func (p *adornPane) paneWashed() []bool { return capturedWashes(p.last) }
557+
558+
func capturedWashes(capture string) []bool {
559+
var out []bool
560+
bg := ""
561+
for _, line := range splitPane(capture) {
562+
at, seen := bg, false
563+
for i := 0; i < len(line); {
564+
if line[i] != 0x1b {
565+
if !seen {
566+
at, seen = bg, true
567+
}
568+
i++
569+
continue
570+
}
571+
j := i + 1
572+
for j < len(line) && !(line[j] >= 'A' && line[j] <= 'Z' || line[j] >= 'a' && line[j] <= 'z') {
573+
j++
574+
}
575+
if j < len(line) {
576+
j++
577+
}
578+
bg = applyBackground(bg, line[i:j])
579+
i = j
580+
}
581+
if !seen {
582+
at = bg
583+
}
584+
out = append(out, at == nodeWashes[0] || at == nodeWashes[1])
585+
}
586+
return out
587+
}
588+
589+
// applyBackground folds one escape into the background in force, spelling it
590+
// the way the palette does so the two can be compared.
591+
func applyBackground(bg, esc string) string {
592+
if !strings.HasSuffix(esc, "m") {
593+
return bg
594+
}
595+
params := strings.Split(strings.TrimSuffix(strings.TrimPrefix(esc, "\x1b["), "m"), ";")
596+
for i := 0; i < len(params); i++ {
597+
switch params[i] {
598+
case "", "0", "49":
599+
bg = ""
600+
case "48":
601+
if i+2 < len(params) && params[i+1] == "5" {
602+
bg = "\x1b[48;5;" + params[i+2] + "m"
603+
i += 2
604+
} else if i+4 < len(params) && params[i+1] == "2" {
605+
bg = "\x1b[48;2;" + strings.Join(params[i+2:i+5], ";") + "m"
606+
i += 4
607+
}
608+
}
609+
}
610+
return bg
611+
}
537612

538613
// clean is a captured row with its renditions dropped.
539614
func clean(row string) string { return visibleText(row) }

โ€Žinternal/cli/adornment_test.goโ€Ž

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,13 @@ func TestDeltaKeyIsTheHalfPageScroll(t *testing.T) {
395395
}
396396
}
397397

398-
// THE WASH STOPS AT THE ADORNMENT'S CHROME. The blank row between a question
399-
// and its rows belongs to the block's coordinate (the snake stands in it) but
400-
// is none of its content, and washing it read as a selection one row taller
401-
// than it was.
402-
func TestWashSkipsTheAdornmentChrome(t *testing.T) {
398+
// THE WASH COVERS THE WHOLE BLOCK, chrome included. The blank row between a
399+
// question and its delta rows carries the block's coordinate but none of its
400+
// content; it used to be left unwashed, which cut a hole in the band. Gluck
401+
// asked for a selection that is defined at every space it covers, so the hole
402+
// is gone: the cue is one continuous lift, and the snake's head remains the
403+
// thing that says which row inside it the cursor is on.
404+
func TestWashCoversTheBlocksChrome(t *testing.T) {
403405
defer term.SetColorMode(term.ColorAlways)()
404406
tr := adornFixture(t)
405407
inq := nodeRef{turn: 1, index: inquiryNode}
@@ -422,17 +424,15 @@ func TestWashSkipsTheAdornmentChrome(t *testing.T) {
422424
if text == "" || chrome == "" {
423425
t.Fatal("fixture: the question must have a text row and an adornment chrome row")
424426
}
425-
if !strings.Contains(text, selBg) {
426-
t.Fatalf("the question's own row is washed: %q", text)
427-
}
428-
if strings.Contains(chrome, selBg) {
429-
t.Fatalf("the adornment's chrome row must not be washed: %q", chrome)
427+
for _, row := range []string{text, chrome} {
428+
if !washed(row) {
429+
t.Fatalf("every row of the selected block is washed: %q", row)
430+
}
430431
}
431432
}
432433

433434
// A DELTA ROW KEEPS ITS SNAKE UNDER THE WASH, and keeps its own colour: the
434-
// head is how a reader tells which row of a washed list is the focused one,
435-
// and the bar cannot stand in that column as well.
435+
// head is how a reader tells which row of a washed list the cursor is on.
436436
func TestWashedDeltaRowKeepsItsSnake(t *testing.T) {
437437
defer term.SetColorMode(term.ColorAlways)()
438438
tr := adornFixture(t)
@@ -448,16 +448,13 @@ func TestWashedDeltaRowKeepsItsSnake(t *testing.T) {
448448
continue
449449
}
450450
line := tr.rowLine(r, "", tr.selectionSpan())
451-
if !strings.Contains(line, selBg) {
451+
if !washed(line) {
452452
t.Fatalf("a selected delta row is washed: %q", line)
453453
}
454454
plain := stripANSI(line)
455455
if !strings.Contains(plain, deltaGlyph) {
456456
t.Fatalf("the snake's head survives the wash: %q", plain)
457457
}
458-
if strings.Contains(plain, "โ–Ž") {
459-
t.Fatalf("the bar must not stand where the snake does: %q", plain)
460-
}
461458
return
462459
}
463460
}

โ€Žinternal/cli/command_listen_test.goโ€Ž

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ func TestOverlaySpecIsOnePositional(t *testing.T) {
197197
}
198198
}
199199

200-
201200
// AND THE BOX'S DOOR IS OPENED. commandFork is driven with one ':' line, and
202201
// what is asserted is the REQUEST a daemon receives: the trunk, the turn and
203202
// the dressing the plan carried. Nothing here compares a parser with itself.

โ€Žinternal/cli/escape_selection_test.goโ€Ž

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package cli
22

33
import (
4-
"strings"
54
"testing"
5+
6+
"github.com/jack-work/figaro/internal/term"
67
)
78

89
// ---------------------------------------------------------------------------
@@ -26,21 +27,22 @@ import (
2627
// the user complained about (no cue in the painted frame).
2728
// ---------------------------------------------------------------------------
2829

29-
// cueRows counts painted rows carrying the selection gutter bar. It reads the
30-
// frame the terminal is actually holding (t.prev), not the model, because the
31-
// bug was invisible in the model: t.selection was already empty.
30+
// cueRows counts painted rows carrying the selection wash. It reads the frame
31+
// the terminal is actually holding (t.prev), not the model, because the bug
32+
// was invisible in the model: t.selection was already empty.
3233
func cueRows(rows []string) int {
3334
n := 0
3435
for _, r := range rows {
35-
if strings.Contains(r, "โ–Ž") {
36+
if washed(r) {
3637
n++
3738
}
3839
}
3940
return n
4041
}
4142

4243
func TestEscapeClearsSelectionCueOnNextFrame(t *testing.T) {
43-
p := newInputProbe(t, true) // pager up: mouse reporting on, Esc bound
44+
defer term.SetColorMode(term.ColorAlways)() // the cue IS the wash
45+
p := newInputProbe(t, true) // pager up: mouse reporting on, Esc bound
4446

4547
// Ctrl-N selects the first node.
4648
feed(t, p.in, "\x0e")

โ€Žinternal/cli/testdata/transcript_frames.goldenโ€Ž

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,19 @@
3535
"\x1b[2mโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m"
3636
"< figaro"
3737
""
38-
"โ–Ž โ”‚ \x1b[38;5;252mweighing the\x1b[m\x1b[38;5;252m options and a second line \x1b[m"
39-
""
40-
"โ–Ž \x1b[38;5;252mThe quick brown fox jumps over the lazy dog,\x1b[m\x1b[38;5;252m \x1b[m"
41-
"โ–Ž \x1b[38;5;252mrepeatedly and at length, past the right\x1b[m\x1b[38;5;252m \x1b[m"
42-
"โ–Ž \x1b[38;5;252mmargin.\x1b[m\x1b[38;5;252m \x1b[m"
43-
"โ–Ž \x1b[38;5;252m \x1b[m\x1b[38;5;252m \x1b[m"
44-
"โ–Ž \x1b[38;5;252mๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆใ‚‚ใ“ใ“ใซใ‚ใ‚Šใพใ™ใ€‚\x1b[m\x1b[38;5;252m \x1b[m"
45-
""
46-
"โ–Žโœ“ $ ls -la โŽ"
47-
"โ–Ž โ”‚ alpha"
48-
"โ–Ž โ”‚ beta tabbed"
49-
"โ–Ž โ”‚ gamma ๆ—ฅๆœฌ่ชž"
50-
"โ–Ž โ”‚ wide wide wide wide wide wide wide wide widโ€ฆ\x1b[0m"
38+
" โ”‚ \x1b[38;5;252mweighing the\x1b[m\x1b[38;5;252m options and a second line \x1b[m"
39+
""
40+
" \x1b[38;5;252mThe quick brown fox jumps over the lazy dog,\x1b[m\x1b[38;5;252m \x1b[m"
41+
" \x1b[38;5;252mrepeatedly and at length, past the right\x1b[m\x1b[38;5;252m \x1b[m"
42+
" \x1b[38;5;252mmargin.\x1b[m\x1b[38;5;252m \x1b[m"
43+
" \x1b[38;5;252m \x1b[m\x1b[38;5;252m \x1b[m"
44+
" \x1b[38;5;252mๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆใ‚‚ใ“ใ“ใซใ‚ใ‚Šใพใ™ใ€‚\x1b[m\x1b[38;5;252m \x1b[m"
45+
""
46+
"โœ“ $ ls -la โŽ"
47+
" โ”‚ alpha"
48+
" โ”‚ beta tabbed"
49+
" โ”‚ gamma ๆ—ฅๆœฌ่ชž"
50+
" โ”‚ wide wide wide wide wide wide wide wide widโ€ฆ\x1b[0m"
5151
""
5252
" \x1b[38;5;252mdone.\x1b[m\x1b[38;5;252m \x1b[m"
5353
## highlighted

0 commit comments

Comments
ย (0)