Merge pull request #88 from bnema/feature/picker-ux
feat(picker): improve completion and responsive previews
16 files · +1066 -103
M README.md +2 -2
| @@ -178,7 +178,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`; | |||
| 178 | 178 | ||
| 179 | 179 | ## Command palette | |
| 180 | 180 | ||
| 181 | - | Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close. | |
| 181 | + | Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Tab to complete the selected command code, Enter to run, and Esc/Ctrl-C to close. With an empty query, Tab completes the most recently successful command; commands that take an argument complete with a trailing space and preserve an argument already entered. | |
| 182 | 182 | ||
| 183 | 183 | Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open. | |
| 184 | 184 | ||
| @@ -206,7 +206,7 @@ Rename prompts are prefilled with the current session name. Empty names and name | |||
| 206 | 206 | ||
| 207 | 207 | ## Session picker | |
| 208 | 208 | ||
| 209 | - | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. | |
| 209 | + | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. Wide pickers place the preview beside the list with a muted divider; narrower pickers stack it below the list, then hide it when neither layout has useful space. A clipped preview stays anchored to the bottom of the target's visible terminal frame and never loads its scrollback. | |
| 210 | 210 | ||
| 211 | 211 | | Key | Action | | |
| 212 | 212 | |---|---| | |
| @@ -178,7 +178,7 @@ Some terminals encode Alt+Space as a non-breaking space instead of `ESC Space`; | |||||
| 178 | 178 | ||||
| 179 | ## Command palette | 179 | ## Command palette | ||
| 180 | 180 | ||||
| 181 | - | Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Enter to run, and Esc/Ctrl-C to close. | 181 | + | Open the palette with Alt+Space. Type a command code or fuzzy-match the label/description. Use Up/Down or Ctrl-N/Ctrl-P to move, Tab to complete the selected command code, Enter to run, and Esc/Ctrl-C to close. With an empty query, Tab completes the most recently successful command; commands that take an argument complete with a trailing space and preserve an argument already entered. |
| 182 | 182 | ||||
| 183 | Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open. | 183 | Successful commands are promoted near the top the next time the palette opens. `BSK` toggles back to your previously active session. Typing `JRS` lists recent sessions by number in the status bar, and `JRS N` jumps to number N; the numbers are captured when you start typing, so a busy MRU list never shifts under you. An invalid or stale number leaves the palette open. | ||
| 184 | 184 | ||||
| @@ -206,7 +206,7 @@ Rename prompts are prefilled with the current session name. Empty names and name | |||||
| 206 | 206 | ||||
| 207 | ## Session picker | 207 | ## Session picker | ||
| 208 | 208 | ||||
| 209 | - | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. | 209 | + | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. Wide pickers place the preview beside the list with a muted divider; narrower pickers stack it below the list, then hide it when neither layout has useful space. A clipped preview stays anchored to the bottom of the target's visible terminal frame and never loads its scrollback. |
| 210 | 210 | ||||
| 211 | | Key | Action | | 211 | | Key | Action | | ||
| 212 | |---|---| | 212 | |---|---| | ||
M internal/usecase/daemon/overlay.go +4 -0
| @@ -9,6 +9,7 @@ type overlayEvents struct { | |||
| 9 | 9 | rune func(rune) | |
| 10 | 10 | backspace func() | |
| 11 | 11 | enter func() | |
| 12 | + | tab func() | |
| 12 | 13 | cancel func() | |
| 13 | 14 | up func() | |
| 14 | 15 | down func() | |
| @@ -51,6 +52,9 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | |||
| 51 | 52 | case '\r', '\n': | |
| 52 | 53 | call(ev.enter) | |
| 53 | 54 | i++ | |
| 55 | + | case 0x09: | |
| 56 | + | call(ev.tab) | |
| 57 | + | i++ | |
| 54 | 58 | case 0x7f, 0x08: | |
| 55 | 59 | call(ev.backspace) | |
| 56 | 60 | i++ | |
| @@ -9,6 +9,7 @@ type overlayEvents struct { | |||||
| 9 | rune func(rune) | 9 | rune func(rune) | ||
| 10 | backspace func() | 10 | backspace func() | ||
| 11 | enter func() | 11 | enter func() | ||
| 12 | + | tab func() | |||
| 12 | cancel func() | 13 | cancel func() | ||
| 13 | up func() | 14 | up func() | ||
| 14 | down func() | 15 | down func() | ||
| @@ -51,6 +52,9 @@ func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | |||||
| 51 | case '\r', '\n': | 52 | case '\r', '\n': | ||
| 52 | call(ev.enter) | 53 | call(ev.enter) | ||
| 53 | i++ | 54 | i++ | ||
| 55 | + | case 0x09: | |||
| 56 | + | call(ev.tab) | |||
| 57 | + | i++ | |||
| 54 | case 0x7f, 0x08: | 58 | case 0x7f, 0x08: | ||
| 55 | call(ev.backspace) | 59 | call(ev.backspace) | ||
| 56 | i++ | 60 | i++ | ||
M internal/usecase/daemon/overlay_test.go +17 -1
| @@ -19,6 +19,8 @@ func TestRouteOverlayBytes(t *testing.T) { | |||
| 19 | 19 | wantCancel int | |
| 20 | 20 | wantUp int | |
| 21 | 21 | wantDown int | |
| 22 | + | wantTab int | |
| 23 | + | callback bool | |
| 22 | 24 | }{ | |
| 23 | 25 | { | |
| 24 | 26 | name: "text and split utf8", | |
| @@ -76,13 +78,23 @@ func TestRouteOverlayBytes(t *testing.T) { | |||
| 76 | 78 | chunks: [][]byte{{'a', 0x01, 0xff, 'b'}}, | |
| 77 | 79 | wantRunes: []rune{'a', 'b'}, | |
| 78 | 80 | }, | |
| 81 | + | { | |
| 82 | + | name: "tab routes to callback", | |
| 83 | + | chunks: [][]byte{{0x09}}, | |
| 84 | + | wantTab: 1, | |
| 85 | + | callback: true, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "tab without callback is consumed", | |
| 89 | + | chunks: [][]byte{{0x09}}, | |
| 90 | + | }, | |
| 79 | 91 | } | |
| 80 | 92 | ||
| 81 | 93 | for _, tt := range tests { | |
| 82 | 94 | t.Run(tt.name, func(t *testing.T) { | |
| 83 | 95 | var pending []byte | |
| 84 | 96 | var gotRunes []rune | |
| 85 | - | var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int | |
| 97 | + | var gotBackspace, gotEnter, gotCancel, gotUp, gotDown, gotTab int | |
| 86 | 98 | ev := overlayEvents{ | |
| 87 | 99 | rune: func(r rune) { gotRunes = append(gotRunes, r) }, | |
| 88 | 100 | backspace: func() { gotBackspace++ }, | |
| @@ -91,6 +103,9 @@ func TestRouteOverlayBytes(t *testing.T) { | |||
| 91 | 103 | up: func() { gotUp++ }, | |
| 92 | 104 | down: func() { gotDown++ }, | |
| 93 | 105 | } | |
| 106 | + | if tt.callback { | |
| 107 | + | ev.tab = func() { gotTab++ } | |
| 108 | + | } | |
| 94 | 109 | ||
| 95 | 110 | for _, chunk := range tt.chunks { | |
| 96 | 111 | routeOverlayBytes(chunk, &pending, ev) | |
| @@ -103,6 +118,7 @@ func TestRouteOverlayBytes(t *testing.T) { | |||
| 103 | 118 | require.Equal(t, tt.wantCancel, gotCancel) | |
| 104 | 119 | require.Equal(t, tt.wantUp, gotUp) | |
| 105 | 120 | require.Equal(t, tt.wantDown, gotDown) | |
| 121 | + | require.Equal(t, tt.wantTab, gotTab) | |
| 106 | 122 | }) | |
| 107 | 123 | } | |
| 108 | 124 | } | |
| @@ -19,6 +19,8 @@ func TestRouteOverlayBytes(t *testing.T) { | |||||
| 19 | wantCancel int | 19 | wantCancel int | ||
| 20 | wantUp int | 20 | wantUp int | ||
| 21 | wantDown int | 21 | wantDown int | ||
| 22 | + | wantTab int | |||
| 23 | + | callback bool | |||
| 22 | }{ | 24 | }{ | ||
| 23 | { | 25 | { | ||
| 24 | name: "text and split utf8", | 26 | name: "text and split utf8", | ||
| @@ -76,13 +78,23 @@ func TestRouteOverlayBytes(t *testing.T) { | |||||
| 76 | chunks: [][]byte{{'a', 0x01, 0xff, 'b'}}, | 78 | chunks: [][]byte{{'a', 0x01, 0xff, 'b'}}, | ||
| 77 | wantRunes: []rune{'a', 'b'}, | 79 | wantRunes: []rune{'a', 'b'}, | ||
| 78 | }, | 80 | }, | ||
| 81 | + | { | |||
| 82 | + | name: "tab routes to callback", | |||
| 83 | + | chunks: [][]byte{{0x09}}, | |||
| 84 | + | wantTab: 1, | |||
| 85 | + | callback: true, | |||
| 86 | + | }, | |||
| 87 | + | { | |||
| 88 | + | name: "tab without callback is consumed", | |||
| 89 | + | chunks: [][]byte{{0x09}}, | |||
| 90 | + | }, | |||
| 79 | } | 91 | } | ||
| 80 | 92 | ||||
| 81 | for _, tt := range tests { | 93 | for _, tt := range tests { | ||
| 82 | t.Run(tt.name, func(t *testing.T) { | 94 | t.Run(tt.name, func(t *testing.T) { | ||
| 83 | var pending []byte | 95 | var pending []byte | ||
| 84 | var gotRunes []rune | 96 | var gotRunes []rune | ||
| 85 | - | var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int | 97 | + | var gotBackspace, gotEnter, gotCancel, gotUp, gotDown, gotTab int |
| 86 | ev := overlayEvents{ | 98 | ev := overlayEvents{ | ||
| 87 | rune: func(r rune) { gotRunes = append(gotRunes, r) }, | 99 | rune: func(r rune) { gotRunes = append(gotRunes, r) }, | ||
| 88 | backspace: func() { gotBackspace++ }, | 100 | backspace: func() { gotBackspace++ }, | ||
| @@ -91,6 +103,9 @@ func TestRouteOverlayBytes(t *testing.T) { | |||||
| 91 | up: func() { gotUp++ }, | 103 | up: func() { gotUp++ }, | ||
| 92 | down: func() { gotDown++ }, | 104 | down: func() { gotDown++ }, | ||
| 93 | } | 105 | } | ||
| 106 | + | if tt.callback { | |||
| 107 | + | ev.tab = func() { gotTab++ } | |||
| 108 | + | } | |||
| 94 | 109 | ||||
| 95 | for _, chunk := range tt.chunks { | 110 | for _, chunk := range tt.chunks { | ||
| 96 | routeOverlayBytes(chunk, &pending, ev) | 111 | routeOverlayBytes(chunk, &pending, ev) | ||
| @@ -103,6 +118,7 @@ func TestRouteOverlayBytes(t *testing.T) { | |||||
| 103 | require.Equal(t, tt.wantCancel, gotCancel) | 118 | require.Equal(t, tt.wantCancel, gotCancel) | ||
| 104 | require.Equal(t, tt.wantUp, gotUp) | 119 | require.Equal(t, tt.wantUp, gotUp) | ||
| 105 | require.Equal(t, tt.wantDown, gotDown) | 120 | require.Equal(t, tt.wantDown, gotDown) | ||
| 121 | + | require.Equal(t, tt.wantTab, gotTab) | |||
| 106 | }) | 122 | }) | ||
| 107 | } | 123 | } | ||
| 108 | } | 124 | } | ||
M internal/usecase/daemon/palette.go +1 -0
| @@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 125 | 125 | ac.overlays.palette.Backspace() | |
| 126 | 126 | changed = true | |
| 127 | 127 | }, | |
| 128 | + | tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed }, | |
| 128 | 129 | up: func() { ac.overlays.palette.Up(); changed = true }, | |
| 129 | 130 | down: func() { ac.overlays.palette.Down(); changed = true }, | |
| 130 | 131 | cancel: func() { cancel = true }, | |
| @@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 125 | ac.overlays.palette.Backspace() | 125 | ac.overlays.palette.Backspace() | ||
| 126 | changed = true | 126 | changed = true | ||
| 127 | }, | 127 | }, | ||
| 128 | + | tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed }, | |||
| 128 | up: func() { ac.overlays.palette.Up(); changed = true }, | 129 | up: func() { ac.overlays.palette.Up(); changed = true }, | ||
| 129 | down: func() { ac.overlays.palette.Down(); changed = true }, | 130 | down: func() { ac.overlays.palette.Down(); changed = true }, | ||
| 130 | cancel: func() { cancel = true }, | 131 | cancel: func() { cancel = true }, | ||
M internal/usecase/daemon/palette_test.go +94 -0
| @@ -530,6 +530,100 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||
| 530 | 530 | wg.Wait() | |
| 531 | 531 | } | |
| 532 | 532 | ||
| 533 | + | func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) { | |
| 534 | + | tests := []struct { | |
| 535 | + | name string | |
| 536 | + | config map[string]string | |
| 537 | + | recent string | |
| 538 | + | query string | |
| 539 | + | want string | |
| 540 | + | wantCompletionInvalidation bool | |
| 541 | + | setupInvalidations int | |
| 542 | + | }{ | |
| 543 | + | {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true}, | |
| 544 | + | {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"}, | |
| 545 | + | {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true}, | |
| 546 | + | {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true}, | |
| 547 | + | {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1}, | |
| 548 | + | {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1}, | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | for _, tt := range tests { | |
| 552 | + | t.Run(tt.name, func(t *testing.T) { | |
| 553 | + | writes := make(chan []byte, 1) | |
| 554 | + | p, release := newBlockingPTYWithWrites(t, writes) | |
| 555 | + | defer release() | |
| 556 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 557 | + | invs := installPaletteInvalidationObserver(sess) | |
| 558 | + | if tt.recent != "" { | |
| 559 | + | d.recordPaletteUse(tt.recent) | |
| 560 | + | } | |
| 561 | + | if tt.config != nil { | |
| 562 | + | d.ApplyConfig(domain.Config{Codes: tt.config}) | |
| 563 | + | for range tt.setupInvalidations { | |
| 564 | + | awaitInvalidation(t, invs) | |
| 565 | + | } | |
| 566 | + | requireNoInvalidation(t, invs) | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 570 | + | awaitInvalidation(t, invs) | |
| 571 | + | requireNoInvalidation(t, invs) | |
| 572 | + | if tt.query != "" { | |
| 573 | + | d.handleInput(sess, ac, []byte(tt.query)) | |
| 574 | + | awaitInvalidation(t, invs) | |
| 575 | + | requireNoInvalidation(t, invs) | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | d.handleInput(sess, ac, []byte("\t")) | |
| 579 | + | ac.overlays.paletteMu.Lock() | |
| 580 | + | got := ac.overlays.palette.Query() | |
| 581 | + | ac.overlays.paletteMu.Unlock() | |
| 582 | + | require.Equal(t, tt.want, got) | |
| 583 | + | if tt.wantCompletionInvalidation { | |
| 584 | + | awaitInvalidation(t, invs) | |
| 585 | + | } | |
| 586 | + | requireNoInvalidation(t, invs) | |
| 587 | + | requireNoOutputFrame(t, sends) | |
| 588 | + | requireNoPTYWrite(t, writes) | |
| 589 | + | }) | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) { | |
| 594 | + | writes := make(chan []byte, 1) | |
| 595 | + | p, release := newBlockingPTYWithWrites(t, writes) | |
| 596 | + | defer release() | |
| 597 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 598 | + | invs := installPaletteInvalidationObserver(sess) | |
| 599 | + | ||
| 600 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 601 | + | awaitInvalidation(t, invs) | |
| 602 | + | requireNoInvalidation(t, invs) | |
| 603 | + | ||
| 604 | + | d.handleInput(sess, ac, []byte("NX\t\t")) | |
| 605 | + | ac.overlays.paletteMu.Lock() | |
| 606 | + | got := ac.overlays.palette.Query() | |
| 607 | + | ac.overlays.paletteMu.Unlock() | |
| 608 | + | require.Equal(t, "NXT", got) | |
| 609 | + | awaitInvalidation(t, invs) | |
| 610 | + | requireNoInvalidation(t, invs) | |
| 611 | + | requireNoOutputFrame(t, sends) | |
| 612 | + | requireNoPTYWrite(t, writes) | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func installPaletteInvalidationObserver(sess *session) chan renderInvalidation { | |
| 616 | + | invs := make(chan renderInvalidation, 8) | |
| 617 | + | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| 618 | + | clock: nil, | |
| 619 | + | wake: nil, | |
| 620 | + | onInvalidate: func(inv renderInvalidation) { | |
| 621 | + | invs <- inv | |
| 622 | + | }, | |
| 623 | + | })) | |
| 624 | + | return invs | |
| 625 | + | } | |
| 626 | + | ||
| 533 | 627 | func TestPaletteExecMethods(t *testing.T) { | |
| 534 | 628 | p1, release1 := newBlockingPTY(t) | |
| 535 | 629 | p2, release2 := newBlockingPTY(t) | |
| @@ -530,6 +530,100 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||||
| 530 | wg.Wait() | 530 | wg.Wait() | ||
| 531 | } | 531 | } | ||
| 532 | 532 | ||||
| 533 | + | func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) { | |||
| 534 | + | tests := []struct { | |||
| 535 | + | name string | |||
| 536 | + | config map[string]string | |||
| 537 | + | recent string | |||
| 538 | + | query string | |||
| 539 | + | want string | |||
| 540 | + | wantCompletionInvalidation bool | |||
| 541 | + | setupInvalidations int | |||
| 542 | + | }{ | |||
| 543 | + | {name: "changed completion invalidates once", query: "NX", want: "NXT", wantCompletionInvalidation: true}, | |||
| 544 | + | {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1"}, | |||
| 545 | + | {name: "empty query starts at registry first", want: "CNT", wantCompletionInvalidation: true}, | |||
| 546 | + | {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantCompletionInvalidation: true}, | |||
| 547 | + | {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1}, | |||
| 548 | + | {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantCompletionInvalidation: true, setupInvalidations: 1}, | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | for _, tt := range tests { | |||
| 552 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 553 | + | writes := make(chan []byte, 1) | |||
| 554 | + | p, release := newBlockingPTYWithWrites(t, writes) | |||
| 555 | + | defer release() | |||
| 556 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 557 | + | invs := installPaletteInvalidationObserver(sess) | |||
| 558 | + | if tt.recent != "" { | |||
| 559 | + | d.recordPaletteUse(tt.recent) | |||
| 560 | + | } | |||
| 561 | + | if tt.config != nil { | |||
| 562 | + | d.ApplyConfig(domain.Config{Codes: tt.config}) | |||
| 563 | + | for range tt.setupInvalidations { | |||
| 564 | + | awaitInvalidation(t, invs) | |||
| 565 | + | } | |||
| 566 | + | requireNoInvalidation(t, invs) | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 570 | + | awaitInvalidation(t, invs) | |||
| 571 | + | requireNoInvalidation(t, invs) | |||
| 572 | + | if tt.query != "" { | |||
| 573 | + | d.handleInput(sess, ac, []byte(tt.query)) | |||
| 574 | + | awaitInvalidation(t, invs) | |||
| 575 | + | requireNoInvalidation(t, invs) | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | d.handleInput(sess, ac, []byte("\t")) | |||
| 579 | + | ac.overlays.paletteMu.Lock() | |||
| 580 | + | got := ac.overlays.palette.Query() | |||
| 581 | + | ac.overlays.paletteMu.Unlock() | |||
| 582 | + | require.Equal(t, tt.want, got) | |||
| 583 | + | if tt.wantCompletionInvalidation { | |||
| 584 | + | awaitInvalidation(t, invs) | |||
| 585 | + | } | |||
| 586 | + | requireNoInvalidation(t, invs) | |||
| 587 | + | requireNoOutputFrame(t, sends) | |||
| 588 | + | requireNoPTYWrite(t, writes) | |||
| 589 | + | }) | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | func TestPaletteBatchedTabCompletionPreservesRepaintInvalidation(t *testing.T) { | |||
| 594 | + | writes := make(chan []byte, 1) | |||
| 595 | + | p, release := newBlockingPTYWithWrites(t, writes) | |||
| 596 | + | defer release() | |||
| 597 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 598 | + | invs := installPaletteInvalidationObserver(sess) | |||
| 599 | + | ||||
| 600 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 601 | + | awaitInvalidation(t, invs) | |||
| 602 | + | requireNoInvalidation(t, invs) | |||
| 603 | + | ||||
| 604 | + | d.handleInput(sess, ac, []byte("NX\t\t")) | |||
| 605 | + | ac.overlays.paletteMu.Lock() | |||
| 606 | + | got := ac.overlays.palette.Query() | |||
| 607 | + | ac.overlays.paletteMu.Unlock() | |||
| 608 | + | require.Equal(t, "NXT", got) | |||
| 609 | + | awaitInvalidation(t, invs) | |||
| 610 | + | requireNoInvalidation(t, invs) | |||
| 611 | + | requireNoOutputFrame(t, sends) | |||
| 612 | + | requireNoPTYWrite(t, writes) | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | func installPaletteInvalidationObserver(sess *session) chan renderInvalidation { | |||
| 616 | + | invs := make(chan renderInvalidation, 8) | |||
| 617 | + | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |||
| 618 | + | clock: nil, | |||
| 619 | + | wake: nil, | |||
| 620 | + | onInvalidate: func(inv renderInvalidation) { | |||
| 621 | + | invs <- inv | |||
| 622 | + | }, | |||
| 623 | + | })) | |||
| 624 | + | return invs | |||
| 625 | + | } | |||
| 626 | + | ||||
| 533 | func TestPaletteExecMethods(t *testing.T) { | 627 | func TestPaletteExecMethods(t *testing.T) { | ||
| 534 | p1, release1 := newBlockingPTY(t) | 628 | p1, release1 := newBlockingPTY(t) | ||
| 535 | p2, release2 := newBlockingPTY(t) | 629 | p2, release2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/picker_test.go +127 -0
| @@ -15,6 +15,8 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | + | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 20 | "github.com/bnema/vev/pkg/vt" | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| @@ -545,6 +547,131 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||
| 545 | 547 | require.True(t, subscribed) | |
| 546 | 548 | } | |
| 547 | 549 | ||
| 550 | + | func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) { | |
| 551 | + | for _, tt := range []struct { | |
| 552 | + | name string | |
| 553 | + | theme themeui.Theme | |
| 554 | + | }{ | |
| 555 | + | {name: "default terminal", theme: themeui.Theme{}}, | |
| 556 | + | {name: "truecolor terminal", theme: themeui.BuiltinDark}, | |
| 557 | + | } { | |
| 558 | + | t.Run(tt.name, func(t *testing.T) { | |
| 559 | + | state := capturedRenderState{ | |
| 560 | + | theme: tt.theme, | |
| 561 | + | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |
| 562 | + | } | |
| 563 | + | snap := &overlayRenderSnapshot{ | |
| 564 | + | pickerActive: true, | |
| 565 | + | pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0), | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 569 | + | ||
| 570 | + | inner := state.overlays.picker.inner | |
| 571 | + | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 572 | + | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |
| 573 | + | want := themeui.MutedTextStyle(tt.theme) | |
| 574 | + | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |
| 575 | + | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 576 | + | cell := inner.At(x, y) | |
| 577 | + | require.Equal(t, '│', cell.Rune) | |
| 578 | + | require.True(t, cell.Style.Equal(want)) | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | }) | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |
| 586 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 587 | + | state := capturedRenderState{theme: themeui.BuiltinDark} | |
| 588 | + | cases := []struct { | |
| 589 | + | name string | |
| 590 | + | size domain.Size | |
| 591 | + | marker rune | |
| 592 | + | wantMode picker.LayoutMode | |
| 593 | + | wantSep rune | |
| 594 | + | wantMarker bool | |
| 595 | + | }{ | |
| 596 | + | {name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true}, | |
| 597 | + | {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true}, | |
| 598 | + | {name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly}, | |
| 599 | + | } | |
| 600 | + | for _, tt := range cases { | |
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |
| 602 | + | state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2} | |
| 603 | + | snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil} | |
| 604 | + | state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}} | |
| 605 | + | ||
| 606 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |
| 607 | + | ||
| 608 | + | inner := state.overlays.picker.inner | |
| 609 | + | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 610 | + | require.Equal(t, tt.wantMode, layout.Mode) | |
| 611 | + | got := strings.Join(frameRows(inner), "\n") | |
| 612 | + | if tt.wantMarker { | |
| 613 | + | require.Contains(t, got, string(tt.marker)) | |
| 614 | + | } else { | |
| 615 | + | require.NotContains(t, got, "◆") | |
| 616 | + | require.NotContains(t, got, "◇") | |
| 617 | + | require.NotContains(t, got, "●") | |
| 618 | + | } | |
| 619 | + | if tt.wantSep == 0 { | |
| 620 | + | require.NotContains(t, got, "│") | |
| 621 | + | require.NotContains(t, got, "─") | |
| 622 | + | return | |
| 623 | + | } | |
| 624 | + | if tt.wantSep == '│' { | |
| 625 | + | require.NotContains(t, got, "─") | |
| 626 | + | } else { | |
| 627 | + | require.NotContains(t, got, "│") | |
| 628 | + | } | |
| 629 | + | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |
| 630 | + | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |
| 631 | + | cell := inner.At(x, y) | |
| 632 | + | require.Equal(t, tt.wantSep, cell.Rune) | |
| 633 | + | require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme))) | |
| 634 | + | } | |
| 635 | + | } | |
| 636 | + | }) | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) { | |
| 641 | + | withScrollback := newPickerPreviewTabWithHistoryRows(10_000) | |
| 642 | + | require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len()) | |
| 643 | + | ||
| 644 | + | preview := snapshotPickerPreview(withScrollback) | |
| 645 | + | require.Equal(t, 10, preview.Width) | |
| 646 | + | require.Equal(t, 3, preview.Height) | |
| 647 | + | require.Len(t, preview.Rows, preview.Height) | |
| 648 | + | cells := 0 | |
| 649 | + | for _, row := range preview.Rows { | |
| 650 | + | require.Len(t, row, preview.Width) | |
| 651 | + | cells += len(row) | |
| 652 | + | } | |
| 653 | + | require.Equal(t, preview.Width*preview.Height, cells) | |
| 654 | + | got := strings.Join(func() []string { | |
| 655 | + | rows := make([]string, len(preview.Rows)) | |
| 656 | + | for i, row := range preview.Rows { | |
| 657 | + | rows[i] = rowText(row) | |
| 658 | + | } | |
| 659 | + | return rows | |
| 660 | + | }(), "\n") | |
| 661 | + | require.Contains(t, got, "NOW") | |
| 662 | + | require.NotContains(t, got, "history-only-marker") | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func newPickerPreviewTabWithHistoryRows(historyRows int) *tab { | |
| 666 | + | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 667 | + | p := tb.focusedPane() | |
| 668 | + | for range historyRows { | |
| 669 | + | p.screen.History().Append(testRow("history-only-marker")) | |
| 670 | + | } | |
| 671 | + | p.screen.Write([]byte("NOW")) | |
| 672 | + | return tb | |
| 673 | + | } | |
| 674 | + | ||
| 548 | 675 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |
| 549 | 676 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 550 | 677 | p := tb.focusedPane() | |
| @@ -15,6 +15,8 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 17 | "github.com/bnema/vev/internal/usecase/picker" | 17 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 18 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 19 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 18 | "github.com/bnema/vev/pkg/vt" | 20 | "github.com/bnema/vev/pkg/vt" | ||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| @@ -545,6 +547,131 @@ func TestPickerDestroyedTabCleanupDoesNotClearNewerGeneration(t *testing.T) { | |||||
| 545 | require.True(t, subscribed) | 547 | require.True(t, subscribed) | ||
| 546 | } | 548 | } | ||
| 547 | 549 | ||||
| 550 | + | func TestCaptureOverlayLayersUsesMutedThemeStyleForPickerSeparators(t *testing.T) { | |||
| 551 | + | for _, tt := range []struct { | |||
| 552 | + | name string | |||
| 553 | + | theme themeui.Theme | |||
| 554 | + | }{ | |||
| 555 | + | {name: "default terminal", theme: themeui.Theme{}}, | |||
| 556 | + | {name: "truecolor terminal", theme: themeui.BuiltinDark}, | |||
| 557 | + | } { | |||
| 558 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 559 | + | state := capturedRenderState{ | |||
| 560 | + | theme: tt.theme, | |||
| 561 | + | layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}, | |||
| 562 | + | } | |||
| 563 | + | snap := &overlayRenderSnapshot{ | |||
| 564 | + | pickerActive: true, | |||
| 565 | + | pickerModel: picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0), | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 569 | + | ||||
| 570 | + | inner := state.overlays.picker.inner | |||
| 571 | + | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |||
| 572 | + | require.Equal(t, picker.LayoutHorizontal, layout.Mode) | |||
| 573 | + | want := themeui.MutedTextStyle(tt.theme) | |||
| 574 | + | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |||
| 575 | + | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |||
| 576 | + | cell := inner.At(x, y) | |||
| 577 | + | require.Equal(t, '│', cell.Rune) | |||
| 578 | + | require.True(t, cell.Style.Equal(want)) | |||
| 579 | + | } | |||
| 580 | + | } | |||
| 581 | + | }) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) { | |||
| 586 | + | model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |||
| 587 | + | state := capturedRenderState{theme: themeui.BuiltinDark} | |||
| 588 | + | cases := []struct { | |||
| 589 | + | name string | |||
| 590 | + | size domain.Size | |||
| 591 | + | marker rune | |||
| 592 | + | wantMode picker.LayoutMode | |||
| 593 | + | wantSep rune | |||
| 594 | + | wantMarker bool | |||
| 595 | + | }{ | |||
| 596 | + | {name: "horizontal", size: domain.Size{Cols: 100, Rows: 40}, marker: '◆', wantMode: picker.LayoutHorizontal, wantSep: '│', wantMarker: true}, | |||
| 597 | + | {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, marker: '◇', wantMode: picker.LayoutStacked, wantSep: '─', wantMarker: true}, | |||
| 598 | + | {name: "list only", size: domain.Size{Cols: 24, Rows: 11}, marker: '●', wantMode: picker.LayoutListOnly}, | |||
| 599 | + | } | |||
| 600 | + | for _, tt := range cases { | |||
| 601 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 602 | + | state.layout.area = domain.Rect{Width: tt.size.Cols, Height: tt.size.Rows - 2} | |||
| 603 | + | snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model, previewTab: nil} | |||
| 604 | + | state.preview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: tt.marker, Style: renderer.DefaultStyle()}}}} | |||
| 605 | + | ||||
| 606 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 607 | + | ||||
| 608 | + | inner := state.overlays.picker.inner | |||
| 609 | + | layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |||
| 610 | + | require.Equal(t, tt.wantMode, layout.Mode) | |||
| 611 | + | got := strings.Join(frameRows(inner), "\n") | |||
| 612 | + | if tt.wantMarker { | |||
| 613 | + | require.Contains(t, got, string(tt.marker)) | |||
| 614 | + | } else { | |||
| 615 | + | require.NotContains(t, got, "◆") | |||
| 616 | + | require.NotContains(t, got, "◇") | |||
| 617 | + | require.NotContains(t, got, "●") | |||
| 618 | + | } | |||
| 619 | + | if tt.wantSep == 0 { | |||
| 620 | + | require.NotContains(t, got, "│") | |||
| 621 | + | require.NotContains(t, got, "─") | |||
| 622 | + | return | |||
| 623 | + | } | |||
| 624 | + | if tt.wantSep == '│' { | |||
| 625 | + | require.NotContains(t, got, "─") | |||
| 626 | + | } else { | |||
| 627 | + | require.NotContains(t, got, "│") | |||
| 628 | + | } | |||
| 629 | + | for y := layout.Separator.Y; y < layout.Separator.Y+layout.Separator.Height; y++ { | |||
| 630 | + | for x := layout.Separator.X; x < layout.Separator.X+layout.Separator.Width; x++ { | |||
| 631 | + | cell := inner.At(x, y) | |||
| 632 | + | require.Equal(t, tt.wantSep, cell.Rune) | |||
| 633 | + | require.True(t, cell.Style.Equal(themeui.MutedTextStyle(state.theme))) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | }) | |||
| 637 | + | } | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | func TestPickerPreviewContainsOnlyVisibleFrameRowsWithLargeScrollback(t *testing.T) { | |||
| 641 | + | withScrollback := newPickerPreviewTabWithHistoryRows(10_000) | |||
| 642 | + | require.Equal(t, 10_000, withScrollback.focusedPane().screen.History().Len()) | |||
| 643 | + | ||||
| 644 | + | preview := snapshotPickerPreview(withScrollback) | |||
| 645 | + | require.Equal(t, 10, preview.Width) | |||
| 646 | + | require.Equal(t, 3, preview.Height) | |||
| 647 | + | require.Len(t, preview.Rows, preview.Height) | |||
| 648 | + | cells := 0 | |||
| 649 | + | for _, row := range preview.Rows { | |||
| 650 | + | require.Len(t, row, preview.Width) | |||
| 651 | + | cells += len(row) | |||
| 652 | + | } | |||
| 653 | + | require.Equal(t, preview.Width*preview.Height, cells) | |||
| 654 | + | got := strings.Join(func() []string { | |||
| 655 | + | rows := make([]string, len(preview.Rows)) | |||
| 656 | + | for i, row := range preview.Rows { | |||
| 657 | + | rows[i] = rowText(row) | |||
| 658 | + | } | |||
| 659 | + | return rows | |||
| 660 | + | }(), "\n") | |||
| 661 | + | require.Contains(t, got, "NOW") | |||
| 662 | + | require.NotContains(t, got, "history-only-marker") | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | func newPickerPreviewTabWithHistoryRows(historyRows int) *tab { | |||
| 666 | + | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |||
| 667 | + | p := tb.focusedPane() | |||
| 668 | + | for range historyRows { | |||
| 669 | + | p.screen.History().Append(testRow("history-only-marker")) | |||
| 670 | + | } | |||
| 671 | + | p.screen.Write([]byte("NOW")) | |||
| 672 | + | return tb | |||
| 673 | + | } | |||
| 674 | + | ||||
| 548 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | 675 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | ||
| 549 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 676 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 550 | p := tb.focusedPane() | 677 | p := tb.focusedPane() | ||
M internal/usecase/daemon/render.go +2 -0
| @@ -371,6 +371,7 @@ type themeStyles struct { | |||
| 371 | 371 | pickerName renderer.Style | |
| 372 | 372 | pickerSelectionName renderer.Style | |
| 373 | 373 | pickerSelectionMuted renderer.Style | |
| 374 | + | pickerSeparator renderer.Style | |
| 374 | 375 | } | |
| 375 | 376 | ||
| 376 | 377 | func newThemeStyles(t themeui.Theme) themeStyles { | |
| @@ -393,6 +394,7 @@ func newThemeStyles(t themeui.Theme) themeStyles { | |||
| 393 | 394 | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | |
| 394 | 395 | pickerSelectionName: themeui.EmphasisStyle(selection, t), | |
| 395 | 396 | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | |
| 397 | + | pickerSeparator: themeui.MutedTextStyle(t), | |
| 396 | 398 | } | |
| 397 | 399 | } | |
| 398 | 400 | ||
| @@ -371,6 +371,7 @@ type themeStyles struct { | |||||
| 371 | pickerName renderer.Style | 371 | pickerName renderer.Style | ||
| 372 | pickerSelectionName renderer.Style | 372 | pickerSelectionName renderer.Style | ||
| 373 | pickerSelectionMuted renderer.Style | 373 | pickerSelectionMuted renderer.Style | ||
| 374 | + | pickerSeparator renderer.Style | |||
| 374 | } | 375 | } | ||
| 375 | 376 | ||||
| 376 | func newThemeStyles(t themeui.Theme) themeStyles { | 377 | func newThemeStyles(t themeui.Theme) themeStyles { | ||
| @@ -393,6 +394,7 @@ func newThemeStyles(t themeui.Theme) themeStyles { | |||||
| 393 | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | 394 | pickerName: themeui.EmphasisStyle(renderer.DefaultStyle(), t), | ||
| 394 | pickerSelectionName: themeui.EmphasisStyle(selection, t), | 395 | pickerSelectionName: themeui.EmphasisStyle(selection, t), | ||
| 395 | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | 396 | pickerSelectionMuted: themeui.MutedVariantStyle(selection, t), | ||
| 397 | + | pickerSeparator: themeui.MutedTextStyle(t), | |||
| 396 | } | 398 | } | ||
| 397 | } | 399 | } | ||
| 398 | 400 | ||||
M internal/usecase/daemon/render_pipeline.go +1 -1
| @@ -184,7 +184,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 184 | 184 | } | |
| 185 | 185 | if snap.pickerActive && snap.pickerModel != nil { | |
| 186 | 186 | o.picker.modal = pickerModal | |
| 187 | - | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()} | |
| 187 | + | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator} | |
| 188 | 188 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | |
| 189 | 189 | } | |
| 190 | 190 | if snap.paletteActive && snap.paletteModel != nil { | |
| @@ -184,7 +184,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 184 | } | 184 | } | ||
| 185 | if snap.pickerActive && snap.pickerModel != nil { | 185 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 186 | o.picker.modal = pickerModal | 186 | o.picker.modal = pickerModal | ||
| 187 | - | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()} | 187 | + | renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle(), Separator: styles.pickerSeparator} |
| 188 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 188 | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | ||
| 189 | } | 189 | } | ||
| 190 | if snap.paletteActive && snap.paletteModel != nil { | 190 | if snap.paletteActive && snap.paletteModel != nil { | ||
M internal/usecase/palette/model.go +78 -10
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package palette | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "unicode" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) { | |||
| 86 | 88 | return m.matches[m.selected].Command, true | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 91 | + | // CompleteSelected replaces the query with the selected command's effective | |
| 92 | + | // code. Required-argument commands retain the typed argument bytes. | |
| 93 | + | func (m *Model) CompleteSelected() bool { | |
| 94 | + | selected, ok := m.Selected() | |
| 95 | + | if !ok { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | query := m.Query() | |
| 100 | + | completed := selected.Code | |
| 101 | + | if selected.Arguments == command.ArgumentsRequired { | |
| 102 | + | token, args, hasSeparator := completionParts(query) | |
| 103 | + | if token == selected.Code && hasSeparator && args != "" { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | completed += " " | |
| 107 | + | if args != "" { | |
| 108 | + | completed += args | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | if completed == query { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | m.input.SetValue(completed) | |
| 116 | + | m.selected = 0 | |
| 117 | + | m.scroll = 0 | |
| 118 | + | m.refresh() | |
| 119 | + | return true | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // completionParts splits the first token at Unicode whitespace and returns | |
| 123 | + | // the argument beginning at its first non-whitespace byte. | |
| 124 | + | func completionParts(query string) (token, args string, hasSeparator bool) { | |
| 125 | + | for i, r := range query { | |
| 126 | + | if !unicode.IsSpace(r) { | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | for j, argumentRune := range query[i:] { | |
| 130 | + | if !unicode.IsSpace(argumentRune) { | |
| 131 | + | return query[:i], query[i+j:], true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return query[:i], "", true | |
| 135 | + | } | |
| 136 | + | return query, "", false | |
| 137 | + | } | |
| 138 | + | ||
| 89 | 139 | // ArgumentCommand returns the exact argument-taking command being entered. | |
| 90 | 140 | func (m *Model) ArgumentCommand() (command.Command, bool) { | |
| 91 | 141 | if m == nil { | |
| @@ -107,23 +157,41 @@ func (m *Model) Matches() []Match { | |||
| 107 | 157 | } | |
| 108 | 158 | ||
| 109 | 159 | func (m *Model) refresh() { | |
| 110 | - | m.matches = Fuzzy(m.commands, m.input.Value()) | |
| 160 | + | query := m.input.Value() | |
| 161 | + | m.matches = Fuzzy(m.commands, query) | |
| 111 | 162 | // Once an argument-taking token is exact, retain its row while its | |
| 112 | 163 | // arguments make ordinary fuzzy matching inapplicable. | |
| 113 | - | if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok { | |
| 114 | - | found := false | |
| 115 | - | for _, match := range m.matches { | |
| 116 | - | if match.Command.Code == cmd.Code { | |
| 117 | - | found = true | |
| 118 | - | break | |
| 164 | + | if cmd, ok := ArgumentCommand(m.commands, query); ok { | |
| 165 | + | m.prependMatch(cmd) | |
| 166 | + | } else if len(m.matches) == 0 { | |
| 167 | + | // Arguments are not part of static command matching. When they would | |
| 168 | + | // otherwise clear the list, keep required-argument candidates matched by | |
| 169 | + | // the partial first token so Tab can complete them. | |
| 170 | + | token, _, hasSeparator := completionParts(query) | |
| 171 | + | if hasSeparator { | |
| 172 | + | for _, match := range Fuzzy(m.commands, token) { | |
| 173 | + | if match.Command.Arguments == command.ArgumentsRequired { | |
| 174 | + | m.matches = append(m.matches, match) | |
| 175 | + | } | |
| 119 | 176 | } | |
| 120 | 177 | } | |
| 121 | - | if !found { | |
| 122 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |
| 123 | - | } | |
| 124 | 178 | } | |
| 125 | 179 | m.clamp() | |
| 126 | 180 | } | |
| 181 | + | ||
| 182 | + | func (m *Model) prependMatch(cmd command.Command) { | |
| 183 | + | for i, match := range m.matches { | |
| 184 | + | if match.Command.Code != cmd.Code { | |
| 185 | + | continue | |
| 186 | + | } | |
| 187 | + | if i > 0 { | |
| 188 | + | copy(m.matches[1:i+1], m.matches[:i]) | |
| 189 | + | m.matches[0] = match | |
| 190 | + | } | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |
| 194 | + | } | |
| 127 | 195 | func (m *Model) clamp() { | |
| 128 | 196 | if len(m.matches) == 0 { | |
| 129 | 197 | m.selected = -1 | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package palette | 1 | package palette | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "unicode" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 6 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) { | |||||
| 86 | return m.matches[m.selected].Command, true | 88 | return m.matches[m.selected].Command, true | ||
| 87 | } | 89 | } | ||
| 88 | 90 | ||||
| 91 | + | // CompleteSelected replaces the query with the selected command's effective | |||
| 92 | + | // code. Required-argument commands retain the typed argument bytes. | |||
| 93 | + | func (m *Model) CompleteSelected() bool { | |||
| 94 | + | selected, ok := m.Selected() | |||
| 95 | + | if !ok { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | query := m.Query() | |||
| 100 | + | completed := selected.Code | |||
| 101 | + | if selected.Arguments == command.ArgumentsRequired { | |||
| 102 | + | token, args, hasSeparator := completionParts(query) | |||
| 103 | + | if token == selected.Code && hasSeparator && args != "" { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | completed += " " | |||
| 107 | + | if args != "" { | |||
| 108 | + | completed += args | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | if completed == query { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | m.input.SetValue(completed) | |||
| 116 | + | m.selected = 0 | |||
| 117 | + | m.scroll = 0 | |||
| 118 | + | m.refresh() | |||
| 119 | + | return true | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // completionParts splits the first token at Unicode whitespace and returns | |||
| 123 | + | // the argument beginning at its first non-whitespace byte. | |||
| 124 | + | func completionParts(query string) (token, args string, hasSeparator bool) { | |||
| 125 | + | for i, r := range query { | |||
| 126 | + | if !unicode.IsSpace(r) { | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | for j, argumentRune := range query[i:] { | |||
| 130 | + | if !unicode.IsSpace(argumentRune) { | |||
| 131 | + | return query[:i], query[i+j:], true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return query[:i], "", true | |||
| 135 | + | } | |||
| 136 | + | return query, "", false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 89 | // ArgumentCommand returns the exact argument-taking command being entered. | 139 | // ArgumentCommand returns the exact argument-taking command being entered. | ||
| 90 | func (m *Model) ArgumentCommand() (command.Command, bool) { | 140 | func (m *Model) ArgumentCommand() (command.Command, bool) { | ||
| 91 | if m == nil { | 141 | if m == nil { | ||
| @@ -107,23 +157,41 @@ func (m *Model) Matches() []Match { | |||||
| 107 | } | 157 | } | ||
| 108 | 158 | ||||
| 109 | func (m *Model) refresh() { | 159 | func (m *Model) refresh() { | ||
| 110 | - | m.matches = Fuzzy(m.commands, m.input.Value()) | 160 | + | query := m.input.Value() |
| 161 | + | m.matches = Fuzzy(m.commands, query) | |||
| 111 | // Once an argument-taking token is exact, retain its row while its | 162 | // Once an argument-taking token is exact, retain its row while its | ||
| 112 | // arguments make ordinary fuzzy matching inapplicable. | 163 | // arguments make ordinary fuzzy matching inapplicable. | ||
| 113 | - | if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok { | 164 | + | if cmd, ok := ArgumentCommand(m.commands, query); ok { |
| 114 | - | found := false | 165 | + | m.prependMatch(cmd) |
| 115 | - | for _, match := range m.matches { | 166 | + | } else if len(m.matches) == 0 { |
| 116 | - | if match.Command.Code == cmd.Code { | 167 | + | // Arguments are not part of static command matching. When they would |
| 117 | - | found = true | 168 | + | // otherwise clear the list, keep required-argument candidates matched by |
| 118 | - | break | 169 | + | // the partial first token so Tab can complete them. |
| 170 | + | token, _, hasSeparator := completionParts(query) | |||
| 171 | + | if hasSeparator { | |||
| 172 | + | for _, match := range Fuzzy(m.commands, token) { | |||
| 173 | + | if match.Command.Arguments == command.ArgumentsRequired { | |||
| 174 | + | m.matches = append(m.matches, match) | |||
| 175 | + | } | |||
| 119 | } | 176 | } | ||
| 120 | } | 177 | } | ||
| 121 | - | if !found { | |||
| 122 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |||
| 123 | - | } | |||
| 124 | } | 178 | } | ||
| 125 | m.clamp() | 179 | m.clamp() | ||
| 126 | } | 180 | } | ||
| 181 | + | ||||
| 182 | + | func (m *Model) prependMatch(cmd command.Command) { | |||
| 183 | + | for i, match := range m.matches { | |||
| 184 | + | if match.Command.Code != cmd.Code { | |||
| 185 | + | continue | |||
| 186 | + | } | |||
| 187 | + | if i > 0 { | |||
| 188 | + | copy(m.matches[1:i+1], m.matches[:i]) | |||
| 189 | + | m.matches[0] = match | |||
| 190 | + | } | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |||
| 194 | + | } | |||
| 127 | func (m *Model) clamp() { | 195 | func (m *Model) clamp() { | ||
| 128 | if len(m.matches) == 0 { | 196 | if len(m.matches) == 0 { | ||
| 129 | m.selected = -1 | 197 | m.selected = -1 | ||
M internal/usecase/palette/model_test.go +133 -0
| @@ -67,6 +67,77 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |||
| 67 | 67 | require.Equal(t, []int{0}, fresh[0].Positions) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |
| 71 | + | zzz := cmd("ZZZ", "", "ZZZ 1") | |
| 72 | + | zzz.Arguments = command.ArgumentsRequired | |
| 73 | + | commands := []command.Command{ | |
| 74 | + | cmd("AAA", "", "ZZZ 1"), | |
| 75 | + | zzz, | |
| 76 | + | } | |
| 77 | + | want := Fuzzy(commands, "ZZZ 1")[1] | |
| 78 | + | m := New(commands) | |
| 79 | + | ||
| 80 | + | for _, r := range "ZZZ 1" { | |
| 81 | + | m.Insert(r) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | matches := m.Matches() | |
| 85 | + | require.Len(t, matches, 2) | |
| 86 | + | require.Equal(t, "ZZZ", matches[0].Command.Code) | |
| 87 | + | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | |
| 88 | + | selected, ok := m.Selected() | |
| 89 | + | require.True(t, ok) | |
| 90 | + | require.Equal(t, "ZZZ", selected.Code) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |
| 94 | + | for _, tt := range []struct { | |
| 95 | + | name string | |
| 96 | + | commands func(zzz command.Command) []command.Command | |
| 97 | + | want Match | |
| 98 | + | wantLen int | |
| 99 | + | }{ | |
| 100 | + | { | |
| 101 | + | name: "absent fuzzy match is prepended", | |
| 102 | + | commands: func(zzz command.Command) []command.Command { | |
| 103 | + | return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz} | |
| 104 | + | }, | |
| 105 | + | want: Match{Command: command.Command{ | |
| 106 | + | Code: "ZZZ", | |
| 107 | + | Desc: "unmatched", | |
| 108 | + | Arguments: command.ArgumentsRequired, | |
| 109 | + | }}, | |
| 110 | + | wantLen: 2, | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | name: "first fuzzy match is unchanged", | |
| 114 | + | commands: func(zzz command.Command) []command.Command { | |
| 115 | + | zzz.Desc = "ZZZ 1" | |
| 116 | + | return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")} | |
| 117 | + | }, | |
| 118 | + | want: func() Match { | |
| 119 | + | zzz := cmd("ZZZ", "", "ZZZ 1") | |
| 120 | + | zzz.Arguments = command.ArgumentsRequired | |
| 121 | + | return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0] | |
| 122 | + | }(), | |
| 123 | + | wantLen: 2, | |
| 124 | + | }, | |
| 125 | + | } { | |
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |
| 127 | + | zzz := cmd("ZZZ", "", "unmatched") | |
| 128 | + | zzz.Arguments = command.ArgumentsRequired | |
| 129 | + | m := New(tt.commands(zzz)) | |
| 130 | + | for _, r := range "ZZZ 1" { | |
| 131 | + | m.Insert(r) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | matches := m.Matches() | |
| 135 | + | require.Len(t, matches, tt.wantLen) | |
| 136 | + | require.Equal(t, tt.want, matches[0]) | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 70 | 141 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |
| 71 | 142 | m := New([]command.Command{ | |
| 72 | 143 | cmd("CPY", "Copy", "Enter copy mode"), | |
| @@ -186,6 +257,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 186 | 257 | } | |
| 187 | 258 | } | |
| 188 | 259 | ||
| 260 | + | func TestModelCompleteSelected(t *testing.T) { | |
| 261 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 262 | + | jrs.Arguments = command.ArgumentsRequired | |
| 263 | + | registryFirst := command.Registry()[0] | |
| 264 | + | ||
| 265 | + | tests := []struct { | |
| 266 | + | name string | |
| 267 | + | commands []command.Command | |
| 268 | + | registry bool | |
| 269 | + | query string | |
| 270 | + | down int | |
| 271 | + | want string | |
| 272 | + | wantChange bool | |
| 273 | + | rendered string | |
| 274 | + | }{ | |
| 275 | + | {name: "nil model", want: "", wantChange: false}, | |
| 276 | + | {name: "empty model", commands: nil, want: "", wantChange: false}, | |
| 277 | + | {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code}, | |
| 278 | + | {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true}, | |
| 279 | + | {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true}, | |
| 280 | + | {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true}, | |
| 281 | + | {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true}, | |
| 282 | + | {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true}, | |
| 283 | + | {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true}, | |
| 284 | + | {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true}, | |
| 285 | + | {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false}, | |
| 286 | + | {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false}, | |
| 287 | + | {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true}, | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | for _, tt := range tests { | |
| 291 | + | t.Run(tt.name, func(t *testing.T) { | |
| 292 | + | var m *Model | |
| 293 | + | if tt.name != "nil model" { | |
| 294 | + | if tt.registry { | |
| 295 | + | m = NewRegistry() | |
| 296 | + | } else { | |
| 297 | + | m = New(tt.commands) | |
| 298 | + | } | |
| 299 | + | for _, r := range tt.query { | |
| 300 | + | m.Insert(r) | |
| 301 | + | } | |
| 302 | + | for range tt.down { | |
| 303 | + | m.Down() | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | changed := m.CompleteSelected() | |
| 308 | + | require.Equal(t, tt.wantChange, changed) | |
| 309 | + | if m == nil { | |
| 310 | + | require.Equal(t, tt.want, "") | |
| 311 | + | return | |
| 312 | + | } | |
| 313 | + | require.Equal(t, tt.want, m.Query()) | |
| 314 | + | if tt.rendered != "" { | |
| 315 | + | frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 316 | + | require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2]) | |
| 317 | + | } | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 189 | 322 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 190 | 323 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 191 | 324 | jrs.Arguments = command.ArgumentsRequired | |
| @@ -67,6 +67,77 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |||||
| 67 | require.Equal(t, []int{0}, fresh[0].Positions) | 67 | require.Equal(t, []int{0}, fresh[0].Positions) | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | + | func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||
| 71 | + | zzz := cmd("ZZZ", "", "ZZZ 1") | |||
| 72 | + | zzz.Arguments = command.ArgumentsRequired | |||
| 73 | + | commands := []command.Command{ | |||
| 74 | + | cmd("AAA", "", "ZZZ 1"), | |||
| 75 | + | zzz, | |||
| 76 | + | } | |||
| 77 | + | want := Fuzzy(commands, "ZZZ 1")[1] | |||
| 78 | + | m := New(commands) | |||
| 79 | + | ||||
| 80 | + | for _, r := range "ZZZ 1" { | |||
| 81 | + | m.Insert(r) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | matches := m.Matches() | |||
| 85 | + | require.Len(t, matches, 2) | |||
| 86 | + | require.Equal(t, "ZZZ", matches[0].Command.Code) | |||
| 87 | + | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | |||
| 88 | + | selected, ok := m.Selected() | |||
| 89 | + | require.True(t, ok) | |||
| 90 | + | require.Equal(t, "ZZZ", selected.Code) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 94 | + | for _, tt := range []struct { | |||
| 95 | + | name string | |||
| 96 | + | commands func(zzz command.Command) []command.Command | |||
| 97 | + | want Match | |||
| 98 | + | wantLen int | |||
| 99 | + | }{ | |||
| 100 | + | { | |||
| 101 | + | name: "absent fuzzy match is prepended", | |||
| 102 | + | commands: func(zzz command.Command) []command.Command { | |||
| 103 | + | return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz} | |||
| 104 | + | }, | |||
| 105 | + | want: Match{Command: command.Command{ | |||
| 106 | + | Code: "ZZZ", | |||
| 107 | + | Desc: "unmatched", | |||
| 108 | + | Arguments: command.ArgumentsRequired, | |||
| 109 | + | }}, | |||
| 110 | + | wantLen: 2, | |||
| 111 | + | }, | |||
| 112 | + | { | |||
| 113 | + | name: "first fuzzy match is unchanged", | |||
| 114 | + | commands: func(zzz command.Command) []command.Command { | |||
| 115 | + | zzz.Desc = "ZZZ 1" | |||
| 116 | + | return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")} | |||
| 117 | + | }, | |||
| 118 | + | want: func() Match { | |||
| 119 | + | zzz := cmd("ZZZ", "", "ZZZ 1") | |||
| 120 | + | zzz.Arguments = command.ArgumentsRequired | |||
| 121 | + | return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0] | |||
| 122 | + | }(), | |||
| 123 | + | wantLen: 2, | |||
| 124 | + | }, | |||
| 125 | + | } { | |||
| 126 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 127 | + | zzz := cmd("ZZZ", "", "unmatched") | |||
| 128 | + | zzz.Arguments = command.ArgumentsRequired | |||
| 129 | + | m := New(tt.commands(zzz)) | |||
| 130 | + | for _, r := range "ZZZ 1" { | |||
| 131 | + | m.Insert(r) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | matches := m.Matches() | |||
| 135 | + | require.Len(t, matches, tt.wantLen) | |||
| 136 | + | require.Equal(t, tt.want, matches[0]) | |||
| 137 | + | }) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 70 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | 141 | func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | ||
| 71 | m := New([]command.Command{ | 142 | m := New([]command.Command{ | ||
| 72 | cmd("CPY", "Copy", "Enter copy mode"), | 143 | cmd("CPY", "Copy", "Enter copy mode"), | ||
| @@ -186,6 +257,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 186 | } | 257 | } | ||
| 187 | } | 258 | } | ||
| 188 | 259 | ||||
| 260 | + | func TestModelCompleteSelected(t *testing.T) { | |||
| 261 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") | |||
| 262 | + | jrs.Arguments = command.ArgumentsRequired | |||
| 263 | + | registryFirst := command.Registry()[0] | |||
| 264 | + | ||||
| 265 | + | tests := []struct { | |||
| 266 | + | name string | |||
| 267 | + | commands []command.Command | |||
| 268 | + | registry bool | |||
| 269 | + | query string | |||
| 270 | + | down int | |||
| 271 | + | want string | |||
| 272 | + | wantChange bool | |||
| 273 | + | rendered string | |||
| 274 | + | }{ | |||
| 275 | + | {name: "nil model", want: "", wantChange: false}, | |||
| 276 | + | {name: "empty model", commands: nil, want: "", wantChange: false}, | |||
| 277 | + | {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code}, | |||
| 278 | + | {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true}, | |||
| 279 | + | {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true}, | |||
| 280 | + | {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true}, | |||
| 281 | + | {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true}, | |||
| 282 | + | {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true}, | |||
| 283 | + | {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true}, | |||
| 284 | + | {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true}, | |||
| 285 | + | {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false}, | |||
| 286 | + | {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false}, | |||
| 287 | + | {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true}, | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | for _, tt := range tests { | |||
| 291 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 292 | + | var m *Model | |||
| 293 | + | if tt.name != "nil model" { | |||
| 294 | + | if tt.registry { | |||
| 295 | + | m = NewRegistry() | |||
| 296 | + | } else { | |||
| 297 | + | m = New(tt.commands) | |||
| 298 | + | } | |||
| 299 | + | for _, r := range tt.query { | |||
| 300 | + | m.Insert(r) | |||
| 301 | + | } | |||
| 302 | + | for range tt.down { | |||
| 303 | + | m.Down() | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | changed := m.CompleteSelected() | |||
| 308 | + | require.Equal(t, tt.wantChange, changed) | |||
| 309 | + | if m == nil { | |||
| 310 | + | require.Equal(t, tt.want, "") | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | require.Equal(t, tt.want, m.Query()) | |||
| 314 | + | if tt.rendered != "" { | |||
| 315 | + | frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |||
| 316 | + | require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2]) | |||
| 317 | + | } | |||
| 318 | + | }) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 189 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | 322 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | ||
| 190 | jrs := cmd("JRS", "Jump", "Jump to recent session") | 323 | jrs := cmd("JRS", "Jump", "Jump to recent session") | ||
| 191 | jrs.Arguments = command.ArgumentsRequired | 324 | jrs.Arguments = command.ArgumentsRequired | ||
M internal/usecase/picker/model.go +42 -51
| @@ -7,11 +7,12 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | const ( | |
| 10 | - | MinPreviewWidth = 24 | |
| 11 | - | MinListWidth = 16 | |
| 12 | - | MaxListWidth = 32 | |
| 13 | - | MinPaneHeight = 4 | |
| 14 | - | MinStackHeight = 12 | |
| 10 | + | MinPreviewWidth = 24 | |
| 11 | + | MinHorizontalPreviewWidth = 48 | |
| 12 | + | MinListWidth = 16 | |
| 13 | + | MaxListWidth = 32 | |
| 14 | + | MinPaneHeight = 4 | |
| 15 | + | MinStackHeight = 12 | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | type SessionView struct { | |
| @@ -40,13 +41,16 @@ type RenderStyles struct { | |||
| 40 | 41 | Name renderer.Style // non-selected name segment | |
| 41 | 42 | Detail renderer.Style // non-selected detail segment | |
| 42 | 43 | Base renderer.Style // non-selected fill + suffixes | |
| 44 | + | Separator renderer.Style // preview separator | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | func defaultRenderStyles() RenderStyles { | |
| 46 | 48 | selection := renderer.DefaultStyle() | |
| 47 | 49 | selection.Inverse = true | |
| 48 | 50 | base := renderer.DefaultStyle() | |
| 49 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base} | |
| 51 | + | separator := renderer.DefaultStyle() | |
| 52 | + | separator.Attrs = renderer.AttrDim | |
| 53 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator} | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | type Target struct { | |
| @@ -56,15 +60,22 @@ type Target struct { | |||
| 56 | 60 | Stopped bool | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 59 | - | type Preview struct { | |
| 60 | - | Rows [][]renderer.Cell | |
| 61 | - | Width int | |
| 62 | - | Height int | |
| 63 | - | } | |
| 63 | + | // Preview is a bounded view of the selected pane's visible frame. | |
| 64 | + | type Preview = ui.FrameView | |
| 65 | + | ||
| 66 | + | type LayoutMode uint8 | |
| 67 | + | ||
| 68 | + | const ( | |
| 69 | + | LayoutListOnly LayoutMode = iota | |
| 70 | + | LayoutStacked | |
| 71 | + | LayoutHorizontal | |
| 72 | + | ) | |
| 64 | 73 | ||
| 65 | 74 | type Layout struct { | |
| 66 | - | List domain.Rect | |
| 67 | - | Preview domain.Rect | |
| 75 | + | Mode LayoutMode | |
| 76 | + | List domain.Rect | |
| 77 | + | Separator domain.Rect | |
| 78 | + | Preview domain.Rect | |
| 68 | 79 | } | |
| 69 | 80 | ||
| 70 | 81 | type Model struct { | |
| @@ -125,13 +136,15 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 125 | 136 | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 126 | 137 | return Layout{} | |
| 127 | 138 | } | |
| 128 | - | if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth { | |
| 139 | + | if inner.Rows >= MinPaneHeight { | |
| 129 | 140 | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | |
| 130 | 141 | previewWidth := inner.Cols - listWidth - 1 | |
| 131 | - | if previewWidth >= MinPreviewWidth { | |
| 142 | + | if previewWidth >= MinHorizontalPreviewWidth { | |
| 132 | 143 | return Layout{ | |
| 133 | - | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| 134 | - | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |
| 144 | + | Mode: LayoutHorizontal, | |
| 145 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| 146 | + | Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows}, | |
| 147 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |
| 135 | 148 | } | |
| 136 | 149 | } | |
| 137 | 150 | } | |
| @@ -139,11 +152,13 @@ func ChooseLayout(inner domain.Size) Layout { | |||
| 139 | 152 | listHeight := max(MinPaneHeight, inner.Rows*40/100) | |
| 140 | 153 | previewHeight := inner.Rows - listHeight - 1 | |
| 141 | 154 | return Layout{ | |
| 142 | - | List: domain.Rect{Width: inner.Cols, Height: listHeight}, | |
| 143 | - | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | |
| 155 | + | Mode: LayoutStacked, | |
| 156 | + | List: domain.Rect{Width: inner.Cols, Height: listHeight}, | |
| 157 | + | Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1}, | |
| 158 | + | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | |
| 144 | 159 | } | |
| 145 | 160 | } | |
| 146 | - | return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} | |
| 161 | + | return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} | |
| 147 | 162 | } | |
| 148 | 163 | ||
| 149 | 164 | func (m *Model) Up() { | |
| @@ -182,7 +197,13 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle | |||
| 182 | 197 | styleSet = styles[0] | |
| 183 | 198 | } | |
| 184 | 199 | m.renderList(frame, layout.List, styleSet) | |
| 185 | - | blitPreview(frame, layout.Preview, preview) | |
| 200 | + | switch layout.Mode { | |
| 201 | + | case LayoutHorizontal: | |
| 202 | + | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator) | |
| 203 | + | case LayoutStacked: | |
| 204 | + | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator) | |
| 205 | + | } | |
| 206 | + | ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom) | |
| 186 | 207 | return frame | |
| 187 | 208 | } | |
| 188 | 209 | ||
| @@ -278,33 +299,3 @@ func clamp(n, low, high int) int { | |||
| 278 | 299 | } | |
| 279 | 300 | return n | |
| 280 | 301 | } | |
| 281 | - | ||
| 282 | - | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | |
| 283 | - | if rect.Width <= 0 || rect.Height <= 0 { | |
| 284 | - | return | |
| 285 | - | } | |
| 286 | - | w := min(rect.Width, frame.Width-rect.X) | |
| 287 | - | h := min(rect.Height, frame.Height-rect.Y) | |
| 288 | - | if w <= 0 || h <= 0 { | |
| 289 | - | return | |
| 290 | - | } | |
| 291 | - | for y := range h { | |
| 292 | - | if y >= preview.Height || y >= len(preview.Rows) { | |
| 293 | - | continue | |
| 294 | - | } | |
| 295 | - | src := preview.Rows[y] | |
| 296 | - | for x := range w { | |
| 297 | - | if x >= preview.Width || x >= len(src) { | |
| 298 | - | continue | |
| 299 | - | } | |
| 300 | - | cell := src[x] | |
| 301 | - | if cell.Continuation && x == 0 { | |
| 302 | - | continue | |
| 303 | - | } | |
| 304 | - | if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation { | |
| 305 | - | continue | |
| 306 | - | } | |
| 307 | - | frame.Set(rect.X+x, rect.Y+y, cell) | |
| 308 | - | } | |
| 309 | - | } | |
| 310 | - | } | |
| @@ -7,11 +7,12 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | const ( | 9 | const ( | ||
| 10 | - | MinPreviewWidth = 24 | 10 | + | MinPreviewWidth = 24 |
| 11 | - | MinListWidth = 16 | 11 | + | MinHorizontalPreviewWidth = 48 |
| 12 | - | MaxListWidth = 32 | 12 | + | MinListWidth = 16 |
| 13 | - | MinPaneHeight = 4 | 13 | + | MaxListWidth = 32 |
| 14 | - | MinStackHeight = 12 | 14 | + | MinPaneHeight = 4 |
| 15 | + | MinStackHeight = 12 | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | type SessionView struct { | 18 | type SessionView struct { | ||
| @@ -40,13 +41,16 @@ type RenderStyles struct { | |||||
| 40 | Name renderer.Style // non-selected name segment | 41 | Name renderer.Style // non-selected name segment | ||
| 41 | Detail renderer.Style // non-selected detail segment | 42 | Detail renderer.Style // non-selected detail segment | ||
| 42 | Base renderer.Style // non-selected fill + suffixes | 43 | Base renderer.Style // non-selected fill + suffixes | ||
| 44 | + | Separator renderer.Style // preview separator | |||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| 45 | func defaultRenderStyles() RenderStyles { | 47 | func defaultRenderStyles() RenderStyles { | ||
| 46 | selection := renderer.DefaultStyle() | 48 | selection := renderer.DefaultStyle() | ||
| 47 | selection.Inverse = true | 49 | selection.Inverse = true | ||
| 48 | base := renderer.DefaultStyle() | 50 | base := renderer.DefaultStyle() | ||
| 49 | - | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base} | 51 | + | separator := renderer.DefaultStyle() |
| 52 | + | separator.Attrs = renderer.AttrDim | |||
| 53 | + | return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Base: base, Separator: separator} | |||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 52 | type Target struct { | 56 | type Target struct { | ||
| @@ -56,15 +60,22 @@ type Target struct { | |||||
| 56 | Stopped bool | 60 | Stopped bool | ||
| 57 | } | 61 | } | ||
| 58 | 62 | ||||
| 59 | - | type Preview struct { | 63 | + | // Preview is a bounded view of the selected pane's visible frame. |
| 60 | - | Rows [][]renderer.Cell | 64 | + | type Preview = ui.FrameView |
| 61 | - | Width int | 65 | + | |
| 62 | - | Height int | 66 | + | type LayoutMode uint8 |
| 63 | - | } | 67 | + | |
| 68 | + | const ( | |||
| 69 | + | LayoutListOnly LayoutMode = iota | |||
| 70 | + | LayoutStacked | |||
| 71 | + | LayoutHorizontal | |||
| 72 | + | ) | |||
| 64 | 73 | ||||
| 65 | type Layout struct { | 74 | type Layout struct { | ||
| 66 | - | List domain.Rect | 75 | + | Mode LayoutMode |
| 67 | - | Preview domain.Rect | 76 | + | List domain.Rect |
| 77 | + | Separator domain.Rect | |||
| 78 | + | Preview domain.Rect | |||
| 68 | } | 79 | } | ||
| 69 | 80 | ||||
| 70 | type Model struct { | 81 | type Model struct { | ||
| @@ -125,13 +136,15 @@ func ChooseLayout(inner domain.Size) Layout { | |||||
| 125 | if inner.Cols <= 0 || inner.Rows <= 0 { | 136 | if inner.Cols <= 0 || inner.Rows <= 0 { | ||
| 126 | return Layout{} | 137 | return Layout{} | ||
| 127 | } | 138 | } | ||
| 128 | - | if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth { | 139 | + | if inner.Rows >= MinPaneHeight { |
| 129 | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | 140 | listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth) | ||
| 130 | previewWidth := inner.Cols - listWidth - 1 | 141 | previewWidth := inner.Cols - listWidth - 1 | ||
| 131 | - | if previewWidth >= MinPreviewWidth { | 142 | + | if previewWidth >= MinHorizontalPreviewWidth { |
| 132 | return Layout{ | 143 | return Layout{ | ||
| 133 | - | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | 144 | + | Mode: LayoutHorizontal, |
| 134 | - | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | 145 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, |
| 146 | + | Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows}, | |||
| 147 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |||
| 135 | } | 148 | } | ||
| 136 | } | 149 | } | ||
| 137 | } | 150 | } | ||
| @@ -139,11 +152,13 @@ func ChooseLayout(inner domain.Size) Layout { | |||||
| 139 | listHeight := max(MinPaneHeight, inner.Rows*40/100) | 152 | listHeight := max(MinPaneHeight, inner.Rows*40/100) | ||
| 140 | previewHeight := inner.Rows - listHeight - 1 | 153 | previewHeight := inner.Rows - listHeight - 1 | ||
| 141 | return Layout{ | 154 | return Layout{ | ||
| 142 | - | List: domain.Rect{Width: inner.Cols, Height: listHeight}, | 155 | + | Mode: LayoutStacked, |
| 143 | - | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | 156 | + | List: domain.Rect{Width: inner.Cols, Height: listHeight}, |
| 157 | + | Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1}, | |||
| 158 | + | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | |||
| 144 | } | 159 | } | ||
| 145 | } | 160 | } | ||
| 146 | - | return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} | 161 | + | return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} |
| 147 | } | 162 | } | ||
| 148 | 163 | ||||
| 149 | func (m *Model) Up() { | 164 | func (m *Model) Up() { | ||
| @@ -182,7 +197,13 @@ func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyle | |||||
| 182 | styleSet = styles[0] | 197 | styleSet = styles[0] | ||
| 183 | } | 198 | } | ||
| 184 | m.renderList(frame, layout.List, styleSet) | 199 | m.renderList(frame, layout.List, styleSet) | ||
| 185 | - | blitPreview(frame, layout.Preview, preview) | 200 | + | switch layout.Mode { |
| 201 | + | case LayoutHorizontal: | |||
| 202 | + | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator) | |||
| 203 | + | case LayoutStacked: | |||
| 204 | + | ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator) | |||
| 205 | + | } | |||
| 206 | + | ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom) | |||
| 186 | return frame | 207 | return frame | ||
| 187 | } | 208 | } | ||
| 188 | 209 | ||||
| @@ -278,33 +299,3 @@ func clamp(n, low, high int) int { | |||||
| 278 | } | 299 | } | ||
| 279 | return n | 300 | return n | ||
| 280 | } | 301 | } | ||
| 281 | - | ||||
| 282 | - | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | |||
| 283 | - | if rect.Width <= 0 || rect.Height <= 0 { | |||
| 284 | - | return | |||
| 285 | - | } | |||
| 286 | - | w := min(rect.Width, frame.Width-rect.X) | |||
| 287 | - | h := min(rect.Height, frame.Height-rect.Y) | |||
| 288 | - | if w <= 0 || h <= 0 { | |||
| 289 | - | return | |||
| 290 | - | } | |||
| 291 | - | for y := range h { | |||
| 292 | - | if y >= preview.Height || y >= len(preview.Rows) { | |||
| 293 | - | continue | |||
| 294 | - | } | |||
| 295 | - | src := preview.Rows[y] | |||
| 296 | - | for x := range w { | |||
| 297 | - | if x >= preview.Width || x >= len(src) { | |||
| 298 | - | continue | |||
| 299 | - | } | |||
| 300 | - | cell := src[x] | |||
| 301 | - | if cell.Continuation && x == 0 { | |||
| 302 | - | continue | |||
| 303 | - | } | |||
| 304 | - | if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation { | |||
| 305 | - | continue | |||
| 306 | - | } | |||
| 307 | - | frame.Set(rect.X+x, rect.Y+y, cell) | |||
| 308 | - | } | |||
| 309 | - | } | |||
| 310 | - | } | |||
M internal/usecase/picker/model_test.go +130 -38
| @@ -65,30 +65,122 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||
| 65 | 65 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | func TestChooseLayoutBoundaries(t *testing.T) { | |
| 69 | - | layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12}) | |
| 70 | - | require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List) | |
| 71 | - | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | |
| 68 | + | func TestChooseLayoutResponsiveBoundaries(t *testing.T) { | |
| 69 | + | tests := []struct { | |
| 70 | + | name string | |
| 71 | + | size domain.Size | |
| 72 | + | want Layout | |
| 73 | + | }{ | |
| 74 | + | { | |
| 75 | + | name: "horizontal rows minus one is list only", size: domain.Size{Cols: 69, Rows: 3}, | |
| 76 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 69, Height: 3}}, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "horizontal preview minus one is list only", size: domain.Size{Cols: 68, Rows: 4}, | |
| 80 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 68, Height: 4}}, | |
| 81 | + | }, | |
| 82 | + | { | |
| 83 | + | name: "horizontal exact minimum preview", size: domain.Size{Cols: 69, Rows: 4}, | |
| 84 | + | want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 20, Height: 4}, Separator: domain.Rect{X: 20, Width: 1, Height: 4}, Preview: domain.Rect{X: 21, Width: 48, Height: 4}}, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "horizontal preview plus one", size: domain.Size{Cols: 70, Rows: 4}, | |
| 88 | + | want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 21, Height: 4}, Separator: domain.Rect{X: 21, Width: 1, Height: 4}, Preview: domain.Rect{X: 22, Width: 48, Height: 4}}, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "stacked columns minus one is list only", size: domain.Size{Cols: 23, Rows: 12}, | |
| 92 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 23, Height: 12}}, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "stacked rows minus one is list only", size: domain.Size{Cols: 24, Rows: 11}, | |
| 96 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 24, Height: 11}}, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "stacked exact minimum", size: domain.Size{Cols: 24, Rows: 12}, | |
| 100 | + | want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 4}, Separator: domain.Rect{Y: 4, Width: 24, Height: 1}, Preview: domain.Rect{Y: 5, Width: 24, Height: 7}}, | |
| 101 | + | }, | |
| 102 | + | { | |
| 103 | + | name: "stacked list height plus one", size: domain.Size{Cols: 24, Rows: 13}, | |
| 104 | + | want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 5}, Separator: domain.Rect{Y: 5, Width: 24, Height: 1}, Preview: domain.Rect{Y: 6, Width: 24, Height: 7}}, | |
| 105 | + | }, | |
| 106 | + | } | |
| 107 | + | for _, tt := range tests { | |
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |
| 109 | + | require.Equal(t, tt.want, ChooseLayout(tt.size)) | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |
| 115 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 116 | + | separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim} | |
| 117 | + | styles := RenderStyles{Separator: separator} | |
| 118 | + | tests := []struct { | |
| 119 | + | name string | |
| 120 | + | size domain.Size | |
| 121 | + | rect domain.Rect | |
| 122 | + | rune rune | |
| 123 | + | }{ | |
| 124 | + | {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, | |
| 125 | + | {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, | |
| 126 | + | } | |
| 127 | + | for _, tt := range tests { | |
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |
| 129 | + | frame := m.Render(tt.size, Preview{}, styles) | |
| 130 | + | for y := tt.rect.Y; y < tt.rect.Y+tt.rect.Height; y++ { | |
| 131 | + | for x := tt.rect.X; x < tt.rect.X+tt.rect.Width; x++ { | |
| 132 | + | got := frame.At(x, y) | |
| 133 | + | require.Equal(t, tt.rune, got.Rune) | |
| 134 | + | require.True(t, got.Style.Equal(separator)) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | }) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |
| 142 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |
| 143 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | |
| 72 | 144 | ||
| 73 | - | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | |
| 74 | - | require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List) | |
| 75 | - | require.Equal(t, domain.Rect{}, layout.Preview) | |
| 145 | + | require.Equal(t, ' ', frame.At(22, 10).Rune) | |
| 146 | + | require.NotEqual(t, 'x', frame.At(22, 10).Rune) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |
| 150 | + | m := New(nil, "", 0) | |
| 151 | + | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | |
| 152 | + | ||
| 153 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 154 | + | require.Equal(t, 'c', frame.At(0, 5).Rune) | |
| 155 | + | require.Equal(t, 'i', frame.At(0, 11).Rune) | |
| 156 | + | } | |
| 76 | 157 | ||
| 77 | - | layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4}) | |
| 78 | - | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | |
| 79 | - | require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview) | |
| 158 | + | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { | |
| 159 | + | m := New(nil, "", 0) | |
| 160 | + | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} | |
| 80 | 161 | ||
| 81 | - | layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4}) | |
| 82 | - | require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List) | |
| 83 | - | require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview) | |
| 162 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 163 | + | require.Equal(t, ' ', frame.At(0, 5).Rune) | |
| 164 | + | require.Equal(t, 'z', frame.At(0, 11).Rune) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | |
| 168 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0) | |
| 169 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{}) | |
| 84 | 170 | ||
| 85 | - | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | |
| 86 | - | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | |
| 87 | - | require.Equal(t, domain.Rect{}, layout.Preview) | |
| 171 | + | require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune) | |
| 172 | + | } | |
| 88 | 173 | ||
| 89 | - | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12}) | |
| 90 | - | require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List) | |
| 91 | - | require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview) | |
| 174 | + | func previewRows(width int, labels string) [][]renderer.Cell { | |
| 175 | + | rows := make([][]renderer.Cell, len(labels)) | |
| 176 | + | for y, label := range labels { | |
| 177 | + | rows[y] = make([]renderer.Cell, width) | |
| 178 | + | for x := range rows[y] { | |
| 179 | + | rows[y][x] = cell(' ') | |
| 180 | + | } | |
| 181 | + | rows[y][0] = cell(label) | |
| 182 | + | } | |
| 183 | + | return rows | |
| 92 | 184 | } | |
| 93 | 185 | ||
| 94 | 186 | func TestSelectedMapping(t *testing.T) { | |
| @@ -115,7 +207,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||
| 115 | 207 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 116 | 208 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | |
| 117 | 209 | preview := Preview{ | |
| 118 | - | Width: 24, | |
| 210 | + | Width: 25, | |
| 119 | 211 | Height: 1, | |
| 120 | 212 | Rows: [][]renderer.Cell{{ | |
| 121 | 213 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | |
| @@ -124,12 +216,12 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 124 | 216 | }}, | |
| 125 | 217 | } | |
| 126 | 218 | ||
| 127 | - | frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview) | |
| 219 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |
| 128 | 220 | require.True(t, frame.At(0, 1).Style.Inverse) | |
| 129 | - | require.Equal(t, 'a', frame.At(17, 0).Rune) | |
| 130 | - | require.Equal(t, 'w', frame.At(39, 0).Rune) | |
| 131 | - | require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped") | |
| 132 | - | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | |
| 221 | + | require.Equal(t, 'a', frame.At(0, 11).Rune) | |
| 222 | + | require.Equal(t, 'w', frame.At(22, 11).Rune) | |
| 223 | + | require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped") | |
| 224 | + | require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored") | |
| 133 | 225 | } | |
| 134 | 226 | ||
| 135 | 227 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |
| @@ -147,12 +239,12 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 147 | 239 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| 148 | 240 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | |
| 149 | 241 | ||
| 150 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 242 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 151 | 243 | ||
| 152 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 153 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 244 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 245 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 154 | 246 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | |
| 155 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width") | |
| 247 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | |
| 156 | 248 | } | |
| 157 | 249 | ||
| 158 | 250 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| @@ -212,16 +304,16 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||
| 212 | 304 | {Name: "short-name", Detail: " (a very long detail text)"}, | |
| 213 | 305 | }, Active: 0}}, "s1", 0) | |
| 214 | 306 | ||
| 215 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 216 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 217 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 307 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 308 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 309 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 218 | 310 | ||
| 219 | - | want := " short-name (a…" | |
| 311 | + | want := " short-name (a ver…" | |
| 220 | 312 | for i, r := range want { | |
| 221 | 313 | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | |
| 222 | 314 | } | |
| 223 | 315 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | |
| 224 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | |
| 316 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | |
| 225 | 317 | } | |
| 226 | 318 | ||
| 227 | 319 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |
| @@ -229,12 +321,12 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||
| 229 | 321 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | |
| 230 | 322 | }, Active: 0}}, "s1", 0) | |
| 231 | 323 | ||
| 232 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 233 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 234 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 324 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) | |
| 325 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) | |
| 326 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") | |
| 235 | 327 | ||
| 236 | 328 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | |
| 237 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | |
| 329 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") | |
| 238 | 330 | } | |
| 239 | 331 | ||
| 240 | 332 | func cell(r rune) renderer.Cell { | |
| @@ -65,30 +65,122 @@ func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||||
| 65 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | 65 | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | func TestChooseLayoutBoundaries(t *testing.T) { | 68 | + | func TestChooseLayoutResponsiveBoundaries(t *testing.T) { |
| 69 | - | layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12}) | 69 | + | tests := []struct { |
| 70 | - | require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List) | 70 | + | name string |
| 71 | - | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | 71 | + | size domain.Size |
| 72 | + | want Layout | |||
| 73 | + | }{ | |||
| 74 | + | { | |||
| 75 | + | name: "horizontal rows minus one is list only", size: domain.Size{Cols: 69, Rows: 3}, | |||
| 76 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 69, Height: 3}}, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "horizontal preview minus one is list only", size: domain.Size{Cols: 68, Rows: 4}, | |||
| 80 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 68, Height: 4}}, | |||
| 81 | + | }, | |||
| 82 | + | { | |||
| 83 | + | name: "horizontal exact minimum preview", size: domain.Size{Cols: 69, Rows: 4}, | |||
| 84 | + | want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 20, Height: 4}, Separator: domain.Rect{X: 20, Width: 1, Height: 4}, Preview: domain.Rect{X: 21, Width: 48, Height: 4}}, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "horizontal preview plus one", size: domain.Size{Cols: 70, Rows: 4}, | |||
| 88 | + | want: Layout{Mode: LayoutHorizontal, List: domain.Rect{Width: 21, Height: 4}, Separator: domain.Rect{X: 21, Width: 1, Height: 4}, Preview: domain.Rect{X: 22, Width: 48, Height: 4}}, | |||
| 89 | + | }, | |||
| 90 | + | { | |||
| 91 | + | name: "stacked columns minus one is list only", size: domain.Size{Cols: 23, Rows: 12}, | |||
| 92 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 23, Height: 12}}, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "stacked rows minus one is list only", size: domain.Size{Cols: 24, Rows: 11}, | |||
| 96 | + | want: Layout{Mode: LayoutListOnly, List: domain.Rect{Width: 24, Height: 11}}, | |||
| 97 | + | }, | |||
| 98 | + | { | |||
| 99 | + | name: "stacked exact minimum", size: domain.Size{Cols: 24, Rows: 12}, | |||
| 100 | + | want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 4}, Separator: domain.Rect{Y: 4, Width: 24, Height: 1}, Preview: domain.Rect{Y: 5, Width: 24, Height: 7}}, | |||
| 101 | + | }, | |||
| 102 | + | { | |||
| 103 | + | name: "stacked list height plus one", size: domain.Size{Cols: 24, Rows: 13}, | |||
| 104 | + | want: Layout{Mode: LayoutStacked, List: domain.Rect{Width: 24, Height: 5}, Separator: domain.Rect{Y: 5, Width: 24, Height: 1}, Preview: domain.Rect{Y: 6, Width: 24, Height: 7}}, | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | for _, tt := range tests { | |||
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 109 | + | require.Equal(t, tt.want, ChooseLayout(tt.size)) | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRenderDrawsCustomOrientedSeparators(t *testing.T) { | |||
| 115 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |||
| 116 | + | separator := renderer.Style{Foreground: 8, Attrs: renderer.AttrDim} | |||
| 117 | + | styles := RenderStyles{Separator: separator} | |||
| 118 | + | tests := []struct { | |||
| 119 | + | name string | |||
| 120 | + | size domain.Size | |||
| 121 | + | rect domain.Rect | |||
| 122 | + | rune rune | |||
| 123 | + | }{ | |||
| 124 | + | {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'}, | |||
| 125 | + | {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'}, | |||
| 126 | + | } | |||
| 127 | + | for _, tt := range tests { | |||
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 129 | + | frame := m.Render(tt.size, Preview{}, styles) | |||
| 130 | + | for y := tt.rect.Y; y < tt.rect.Y+tt.rect.Height; y++ { | |||
| 131 | + | for x := tt.rect.X; x < tt.rect.X+tt.rect.Width; x++ { | |||
| 132 | + | got := frame.At(x, y) | |||
| 133 | + | require.Equal(t, tt.rune, got.Rune) | |||
| 134 | + | require.True(t, got.Style.Equal(separator)) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | }) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) { | |||
| 142 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s", 0) | |||
| 143 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}}) | |||
| 72 | 144 | ||||
| 73 | - | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | 145 | + | require.Equal(t, ' ', frame.At(22, 10).Rune) |
| 74 | - | require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List) | 146 | + | require.NotEqual(t, 'x', frame.At(22, 10).Rune) |
| 75 | - | require.Equal(t, domain.Rect{}, layout.Preview) | 147 | + | } |
| 148 | + | ||||
| 149 | + | func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) { | |||
| 150 | + | m := New(nil, "", 0) | |||
| 151 | + | preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")} | |||
| 152 | + | ||||
| 153 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) | |||
| 154 | + | require.Equal(t, 'c', frame.At(0, 5).Rune) | |||
| 155 | + | require.Equal(t, 'i', frame.At(0, 11).Rune) | |||
| 156 | + | } | |||
| 76 | 157 | ||||
| 77 | - | layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4}) | 158 | + | func TestRenderPreviewBottomPlacesShortSource(t *testing.T) { |
| 78 | - | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | 159 | + | m := New(nil, "", 0) |
| 79 | - | require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview) | 160 | + | preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")} |
| 80 | 161 | ||||
| 81 | - | layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4}) | 162 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) |
| 82 | - | require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List) | 163 | + | require.Equal(t, ' ', frame.At(0, 5).Rune) |
| 83 | - | require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview) | 164 | + | require.Equal(t, 'z', frame.At(0, 11).Rune) |
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) { | |||
| 168 | + | m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab", Attention: true}}, Active: 0}}, "s", 0) | |||
| 169 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 4}, Preview{}) | |||
| 84 | 170 | ||||
| 85 | - | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | 171 | + | require.Equal(t, rune(ui.AttentionGlyph), frame.At(6, 1).Rune) |
| 86 | - | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | 172 | + | } |
| 87 | - | require.Equal(t, domain.Rect{}, layout.Preview) | |||
| 88 | 173 | ||||
| 89 | - | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12}) | 174 | + | func previewRows(width int, labels string) [][]renderer.Cell { |
| 90 | - | require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List) | 175 | + | rows := make([][]renderer.Cell, len(labels)) |
| 91 | - | require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview) | 176 | + | for y, label := range labels { |
| 177 | + | rows[y] = make([]renderer.Cell, width) | |||
| 178 | + | for x := range rows[y] { | |||
| 179 | + | rows[y][x] = cell(' ') | |||
| 180 | + | } | |||
| 181 | + | rows[y][0] = cell(label) | |||
| 182 | + | } | |||
| 183 | + | return rows | |||
| 92 | } | 184 | } | ||
| 93 | 185 | ||||
| 94 | func TestSelectedMapping(t *testing.T) { | 186 | func TestSelectedMapping(t *testing.T) { | ||
| @@ -115,7 +207,7 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) { | |||||
| 115 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | 207 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | ||
| 116 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | 208 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, "s1", 0) | ||
| 117 | preview := Preview{ | 209 | preview := Preview{ | ||
| 118 | - | Width: 24, | 210 | + | Width: 25, |
| 119 | Height: 1, | 211 | Height: 1, | ||
| 120 | Rows: [][]renderer.Cell{{ | 212 | Rows: [][]renderer.Cell{{ | ||
| 121 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | 213 | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | ||
| @@ -124,12 +216,12 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||||
| 124 | }}, | 216 | }}, | ||
| 125 | } | 217 | } | ||
| 126 | 218 | ||||
| 127 | - | frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview) | 219 | + | frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview) |
| 128 | require.True(t, frame.At(0, 1).Style.Inverse) | 220 | require.True(t, frame.At(0, 1).Style.Inverse) | ||
| 129 | - | require.Equal(t, 'a', frame.At(17, 0).Rune) | 221 | + | require.Equal(t, 'a', frame.At(0, 11).Rune) |
| 130 | - | require.Equal(t, 'w', frame.At(39, 0).Rune) | 222 | + | require.Equal(t, 'w', frame.At(22, 11).Rune) |
| 131 | - | require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped") | 223 | + | require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped") |
| 132 | - | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | 224 | + | require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored") |
| 133 | } | 225 | } | ||
| 134 | 226 | ||||
| 135 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | 227 | func TestRenderListScrollsSelectionIntoView(t *testing.T) { | ||
| @@ -147,12 +239,12 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 147 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | 239 | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | ||
| 148 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | 240 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "a-really-long-focused-pane-tab-label"}}, Active: 0}}, "s1", 0) | ||
| 149 | 241 | ||||
| 150 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | 242 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) |
| 151 | 243 | ||||
| 152 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | 244 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) |
| 153 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | 245 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") |
| 154 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | 246 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | ||
| 155 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width") | 247 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") |
| 156 | } | 248 | } | ||
| 157 | 249 | ||||
| 158 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 250 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| @@ -212,16 +304,16 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) { | |||||
| 212 | {Name: "short-name", Detail: " (a very long detail text)"}, | 304 | {Name: "short-name", Detail: " (a very long detail text)"}, | ||
| 213 | }, Active: 0}}, "s1", 0) | 305 | }, Active: 0}}, "s1", 0) | ||
| 214 | 306 | ||||
| 215 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | 307 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) |
| 216 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | 308 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) |
| 217 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | 309 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") |
| 218 | 310 | ||||
| 219 | - | want := " short-name (a…" | 311 | + | want := " short-name (a ver…" |
| 220 | for i, r := range want { | 312 | for i, r := range want { | ||
| 221 | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | 313 | require.Equal(t, r, frame.At(i, 1).Rune, "cell %d", i) | ||
| 222 | } | 314 | } | ||
| 223 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | 315 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "detail is ellipsized to fit, the intact name is not touched") | ||
| 224 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width") | 316 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") |
| 225 | } | 317 | } | ||
| 226 | 318 | ||||
| 227 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | 319 | func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | ||
| @@ -229,12 +321,12 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) { | |||||
| 229 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | 321 | {Name: "a-really-long-focused-pane-tab-label", Detail: " (detail)"}, | ||
| 230 | }, Active: 0}}, "s1", 0) | 322 | }, Active: 0}}, "s1", 0) | ||
| 231 | 323 | ||||
| 232 | - | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | 324 | + | frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{}) |
| 233 | - | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | 325 | + | layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5}) |
| 234 | - | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | 326 | + | require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column") |
| 235 | 327 | ||||
| 236 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | 328 | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width") | ||
| 237 | - | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing drawn past the list width, detail gets no room at all") | 329 | + | require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list") |
| 238 | } | 330 | } | ||
| 239 | 331 | ||||
| 240 | func cell(r rune) renderer.Cell { | 332 | func cell(r rune) renderer.Cell { | ||
A internal/usecase/ui/frame.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // VerticalAnchor controls which end of a source is retained when its height | |
| 9 | + | // differs from the destination rectangle. Source and destination aliasing is | |
| 10 | + | // unsupported. | |
| 11 | + | type VerticalAnchor uint8 | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | VerticalAnchorTop VerticalAnchor = iota | |
| 15 | + | VerticalAnchorBottom | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // FrameView is a bounded, row-oriented view of cells. | |
| 19 | + | type FrameView struct { | |
| 20 | + | Rows [][]renderer.Cell | |
| 21 | + | Width, Height int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type frameSpan struct { | |
| 25 | + | source, destination, length int | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // BlitFrame copies the valid intersection of src and rect into dst. Uncovered | |
| 29 | + | // destination cells are left untouched. Invalid dimensions, views, rectangles, | |
| 30 | + | // or anchors are ignored. | |
| 31 | + | func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) { | |
| 32 | + | if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | if len(src.Rows) != src.Height { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | for _, row := range src.Rows { | |
| 42 | + | if len(row) != src.Width { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | width := min(src.Width, rect.Width) | |
| 48 | + | x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width) | |
| 49 | + | if !ok { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | height := min(src.Height, rect.Height) | |
| 54 | + | sourceY, destinationOffset := 0, 0 | |
| 55 | + | if anchor == VerticalAnchorBottom { | |
| 56 | + | if src.Height > rect.Height { | |
| 57 | + | sourceY = src.Height - rect.Height | |
| 58 | + | } else { | |
| 59 | + | destinationOffset = rect.Height - src.Height | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height) | |
| 63 | + | if !ok { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height) | |
| 67 | + | if !ok { | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | for dy := range y.length { | |
| 72 | + | row := src.Rows[y.source+dy] | |
| 73 | + | for dx := range x.length { | |
| 74 | + | sourceX := x.source + dx | |
| 75 | + | current := row[sourceX] | |
| 76 | + | if current.Continuation { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | if renderer.RuneWidth(current.Rune) == 2 { | |
| 80 | + | next := sourceX + 1 | |
| 81 | + | if dx+1 >= x.length || !row[next].Continuation { | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | dst.Set(x.destination+dx, y.destination+dy, current) | |
| 85 | + | dst.Set(x.destination+dx+1, y.destination+dy, row[next]) | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | dst.Set(x.destination+dx, y.destination+dy, current) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // frameDestinationOrigin adds an in-rectangle offset only after proving that | |
| 94 | + | // a non-negative origin remains in the destination's bounded coordinate range. | |
| 95 | + | func frameDestinationOrigin(origin, offset, limit int) (int, bool) { | |
| 96 | + | if origin >= 0 { | |
| 97 | + | if origin >= limit || offset >= limit-origin { | |
| 98 | + | return 0, false | |
| 99 | + | } | |
| 100 | + | return origin + offset, true | |
| 101 | + | } | |
| 102 | + | // A negative integer plus a non-negative offset cannot overflow. | |
| 103 | + | return origin + offset, true | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // clippedFrameSpan intersects a source span placed at origin with [0, limit). | |
| 107 | + | // It avoids forming an unbounded endpoint before both operands are bounded. | |
| 108 | + | func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) { | |
| 109 | + | if origin < 0 { | |
| 110 | + | if origin <= -length { | |
| 111 | + | return frameSpan{}, false | |
| 112 | + | } | |
| 113 | + | trim := -origin | |
| 114 | + | source += trim | |
| 115 | + | length -= trim | |
| 116 | + | origin = 0 | |
| 117 | + | } | |
| 118 | + | if origin >= limit { | |
| 119 | + | return frameSpan{}, false | |
| 120 | + | } | |
| 121 | + | length = min(length, limit-origin) | |
| 122 | + | return frameSpan{source: source, destination: origin, length: length}, length > 0 | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // VerticalAnchor controls which end of a source is retained when its height | |||
| 9 | + | // differs from the destination rectangle. Source and destination aliasing is | |||
| 10 | + | // unsupported. | |||
| 11 | + | type VerticalAnchor uint8 | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | VerticalAnchorTop VerticalAnchor = iota | |||
| 15 | + | VerticalAnchorBottom | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // FrameView is a bounded, row-oriented view of cells. | |||
| 19 | + | type FrameView struct { | |||
| 20 | + | Rows [][]renderer.Cell | |||
| 21 | + | Width, Height int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type frameSpan struct { | |||
| 25 | + | source, destination, length int | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // BlitFrame copies the valid intersection of src and rect into dst. Uncovered | |||
| 29 | + | // destination cells are left untouched. Invalid dimensions, views, rectangles, | |||
| 30 | + | // or anchors are ignored. | |||
| 31 | + | func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) { | |||
| 32 | + | if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom { | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 { | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | if len(src.Rows) != src.Height { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | for _, row := range src.Rows { | |||
| 42 | + | if len(row) != src.Width { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | width := min(src.Width, rect.Width) | |||
| 48 | + | x, ok := clippedFrameSpan(rect.X, 0, width, dst.Width) | |||
| 49 | + | if !ok { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | height := min(src.Height, rect.Height) | |||
| 54 | + | sourceY, destinationOffset := 0, 0 | |||
| 55 | + | if anchor == VerticalAnchorBottom { | |||
| 56 | + | if src.Height > rect.Height { | |||
| 57 | + | sourceY = src.Height - rect.Height | |||
| 58 | + | } else { | |||
| 59 | + | destinationOffset = rect.Height - src.Height | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | destinationY, ok := frameDestinationOrigin(rect.Y, destinationOffset, dst.Height) | |||
| 63 | + | if !ok { | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | y, ok := clippedFrameSpan(destinationY, sourceY, height, dst.Height) | |||
| 67 | + | if !ok { | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | for dy := range y.length { | |||
| 72 | + | row := src.Rows[y.source+dy] | |||
| 73 | + | for dx := range x.length { | |||
| 74 | + | sourceX := x.source + dx | |||
| 75 | + | current := row[sourceX] | |||
| 76 | + | if current.Continuation { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | if renderer.RuneWidth(current.Rune) == 2 { | |||
| 80 | + | next := sourceX + 1 | |||
| 81 | + | if dx+1 >= x.length || !row[next].Continuation { | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | dst.Set(x.destination+dx, y.destination+dy, current) | |||
| 85 | + | dst.Set(x.destination+dx+1, y.destination+dy, row[next]) | |||
| 86 | + | continue | |||
| 87 | + | } | |||
| 88 | + | dst.Set(x.destination+dx, y.destination+dy, current) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // frameDestinationOrigin adds an in-rectangle offset only after proving that | |||
| 94 | + | // a non-negative origin remains in the destination's bounded coordinate range. | |||
| 95 | + | func frameDestinationOrigin(origin, offset, limit int) (int, bool) { | |||
| 96 | + | if origin >= 0 { | |||
| 97 | + | if origin >= limit || offset >= limit-origin { | |||
| 98 | + | return 0, false | |||
| 99 | + | } | |||
| 100 | + | return origin + offset, true | |||
| 101 | + | } | |||
| 102 | + | // A negative integer plus a non-negative offset cannot overflow. | |||
| 103 | + | return origin + offset, true | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // clippedFrameSpan intersects a source span placed at origin with [0, limit). | |||
| 107 | + | // It avoids forming an unbounded endpoint before both operands are bounded. | |||
| 108 | + | func clippedFrameSpan(origin, source, length, limit int) (frameSpan, bool) { | |||
| 109 | + | if origin < 0 { | |||
| 110 | + | if origin <= -length { | |||
| 111 | + | return frameSpan{}, false | |||
| 112 | + | } | |||
| 113 | + | trim := -origin | |||
| 114 | + | source += trim | |||
| 115 | + | length -= trim | |||
| 116 | + | origin = 0 | |||
| 117 | + | } | |||
| 118 | + | if origin >= limit { | |||
| 119 | + | return frameSpan{}, false | |||
| 120 | + | } | |||
| 121 | + | length = min(length, limit-origin) | |||
| 122 | + | return frameSpan{source: source, destination: origin, length: length}, length > 0 | |||
| 123 | + | } | |||
A internal/usecase/ui/frame_test.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | |
| 11 | + | return renderer.Cell{Rune: r, Style: style, Continuation: continuation} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestBlitFrameAnchorsAndClips(t *testing.T) { | |
| 15 | + | style := renderer.Style{Foreground: 1, Background: 2} | |
| 16 | + | tests := []struct { | |
| 17 | + | name string | |
| 18 | + | anchor VerticalAnchor | |
| 19 | + | srcH, srcW, rectX, rectY, rectW, rectH int | |
| 20 | + | want string | |
| 21 | + | }{ | |
| 22 | + | {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"}, | |
| 23 | + | {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"}, | |
| 24 | + | {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "}, | |
| 25 | + | {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"}, | |
| 26 | + | } | |
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)} | |
| 30 | + | for y := range src.Rows { | |
| 31 | + | src.Rows[y] = make([]renderer.Cell, tt.srcW) | |
| 32 | + | for x := range src.Rows[y] { | |
| 33 | + | src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | dst := renderer.NewFrame(5, 5) | |
| 37 | + | BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor) | |
| 38 | + | got := "" | |
| 39 | + | for y := 1; y < 3; y++ { | |
| 40 | + | for x := 1; x < 3; x++ { | |
| 41 | + | got += string(dst.At(x, y).Rune) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if got != tt.want { | |
| 45 | + | t.Fatalf("got %q want %q", got, tt.want) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) { | |
| 52 | + | style := renderer.Style{Foreground: 7, Background: 8, Bold: true} | |
| 53 | + | wide := []renderer.Cell{ | |
| 54 | + | cell('界', style, false), | |
| 55 | + | cell(0, style, true), | |
| 56 | + | cell('x', renderer.DefaultStyle(), false), | |
| 57 | + | } | |
| 58 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |
| 59 | + | tests := []struct { | |
| 60 | + | name string | |
| 61 | + | rect domain.Rect | |
| 62 | + | dst renderer.Frame | |
| 63 | + | want []renderer.Cell | |
| 64 | + | }{ | |
| 65 | + | { | |
| 66 | + | name: "left clipped continuation remains untouched", | |
| 67 | + | rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1}, | |
| 68 | + | dst: markedFrame(3, 1, mark), | |
| 69 | + | want: []renderer.Cell{mark, wide[2], mark}, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | name: "right clipped leader remains untouched", | |
| 73 | + | rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1}, | |
| 74 | + | dst: markedFrame(1, 1, mark), | |
| 75 | + | want: []renderer.Cell{mark}, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "complete pair retains exact cells", | |
| 79 | + | rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1}, | |
| 80 | + | dst: markedFrame(4, 1, mark), | |
| 81 | + | want: []renderer.Cell{mark, wide[0], wide[1], wide[2]}, | |
| 82 | + | }, | |
| 83 | + | } | |
| 84 | + | for _, tt := range tests { | |
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |
| 86 | + | BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop) | |
| 87 | + | assertRowEqual(t, tt.dst, 0, tt.want) | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) { | |
| 93 | + | maxInt := int(^uint(0) >> 1) | |
| 94 | + | minInt := -maxInt - 1 | |
| 95 | + | style := renderer.Style{Foreground: 9} | |
| 96 | + | src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1} | |
| 97 | + | mark := cell('!', renderer.Style{Background: 4}, false) | |
| 98 | + | tests := []struct { | |
| 99 | + | name string | |
| 100 | + | rect domain.Rect | |
| 101 | + | want []renderer.Cell | |
| 102 | + | }{ | |
| 103 | + | {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}}, | |
| 104 | + | {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}}, | |
| 105 | + | {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}}, | |
| 106 | + | } | |
| 107 | + | for _, tt := range tests { | |
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |
| 109 | + | dst := markedFrame(2, 1, mark) | |
| 110 | + | BlitFrame(dst, tt.rect, src, VerticalAnchorTop) | |
| 111 | + | assertRowEqual(t, dst, 0, tt.want) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) { | |
| 117 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |
| 118 | + | dst := renderer.NewFrame(3, 3) | |
| 119 | + | dst.ScrollUp(0, 2, 1) | |
| 120 | + | for y := range dst.Height { | |
| 121 | + | for x := range dst.Width { | |
| 122 | + | dst.Set(x, y, mark) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | src := FrameView{Rows: [][]renderer.Cell{ | |
| 126 | + | {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)}, | |
| 127 | + | {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)}, | |
| 128 | + | }, Width: 2, Height: 2} | |
| 129 | + | ||
| 130 | + | BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop) | |
| 131 | + | ||
| 132 | + | assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark}) | |
| 133 | + | assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark}) | |
| 134 | + | assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark}) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) { | |
| 138 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |
| 139 | + | src := FrameView{Rows: [][]renderer.Cell{ | |
| 140 | + | {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)}, | |
| 141 | + | {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)}, | |
| 142 | + | {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)}, | |
| 143 | + | }, Width: 3, Height: 3} | |
| 144 | + | ||
| 145 | + | leftTop := markedFrame(3, 3, mark) | |
| 146 | + | BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop) | |
| 147 | + | assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark}) | |
| 148 | + | assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark}) | |
| 149 | + | assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark}) | |
| 150 | + | ||
| 151 | + | rightBottom := markedFrame(3, 3, mark) | |
| 152 | + | BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop) | |
| 153 | + | assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark}) | |
| 154 | + | assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark}) | |
| 155 | + | assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]}) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestBlitFrameInvalidNoOp(t *testing.T) { | |
| 159 | + | dst := renderer.NewFrame(2, 2) | |
| 160 | + | before := dst.Clone() | |
| 161 | + | cases := []struct { | |
| 162 | + | r domain.Rect | |
| 163 | + | s FrameView | |
| 164 | + | a VerticalAnchor | |
| 165 | + | }{ | |
| 166 | + | {domain.Rect{}, FrameView{}, VerticalAnchorTop}, | |
| 167 | + | {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop}, | |
| 168 | + | {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)}, | |
| 169 | + | } | |
| 170 | + | for _, tc := range cases { | |
| 171 | + | BlitFrame(dst, tc.r, tc.s, tc.a) | |
| 172 | + | } | |
| 173 | + | if !framesEqual(dst, before) { | |
| 174 | + | t.Fatal("invalid blit changed destination") | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func markedFrame(width, height int, mark renderer.Cell) renderer.Frame { | |
| 179 | + | frame := renderer.NewFrame(width, height) | |
| 180 | + | for y := range height { | |
| 181 | + | for x := range width { | |
| 182 | + | frame.Set(x, y, mark) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | return frame | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) { | |
| 189 | + | t.Helper() | |
| 190 | + | if len(want) != frame.Width { | |
| 191 | + | t.Fatalf("want row length %d, frame width %d", len(want), frame.Width) | |
| 192 | + | } | |
| 193 | + | for x := range want { | |
| 194 | + | if got := frame.At(x, y); !got.Equal(want[x]) { | |
| 195 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x]) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | |||
| 11 | + | return renderer.Cell{Rune: r, Style: style, Continuation: continuation} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestBlitFrameAnchorsAndClips(t *testing.T) { | |||
| 15 | + | style := renderer.Style{Foreground: 1, Background: 2} | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | anchor VerticalAnchor | |||
| 19 | + | srcH, srcW, rectX, rectY, rectW, rectH int | |||
| 20 | + | want string | |||
| 21 | + | }{ | |||
| 22 | + | {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"}, | |||
| 23 | + | {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"}, | |||
| 24 | + | {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "}, | |||
| 25 | + | {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"}, | |||
| 26 | + | } | |||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)} | |||
| 30 | + | for y := range src.Rows { | |||
| 31 | + | src.Rows[y] = make([]renderer.Cell, tt.srcW) | |||
| 32 | + | for x := range src.Rows[y] { | |||
| 33 | + | src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, false) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | dst := renderer.NewFrame(5, 5) | |||
| 37 | + | BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor) | |||
| 38 | + | got := "" | |||
| 39 | + | for y := 1; y < 3; y++ { | |||
| 40 | + | for x := 1; x < 3; x++ { | |||
| 41 | + | got += string(dst.At(x, y).Rune) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if got != tt.want { | |||
| 45 | + | t.Fatalf("got %q want %q", got, tt.want) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestBlitFrameNeverSplitsWidePairsAtClippedEdges(t *testing.T) { | |||
| 52 | + | style := renderer.Style{Foreground: 7, Background: 8, Bold: true} | |||
| 53 | + | wide := []renderer.Cell{ | |||
| 54 | + | cell('界', style, false), | |||
| 55 | + | cell(0, style, true), | |||
| 56 | + | cell('x', renderer.DefaultStyle(), false), | |||
| 57 | + | } | |||
| 58 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |||
| 59 | + | tests := []struct { | |||
| 60 | + | name string | |||
| 61 | + | rect domain.Rect | |||
| 62 | + | dst renderer.Frame | |||
| 63 | + | want []renderer.Cell | |||
| 64 | + | }{ | |||
| 65 | + | { | |||
| 66 | + | name: "left clipped continuation remains untouched", | |||
| 67 | + | rect: domain.Rect{X: -1, Y: 0, Width: 3, Height: 1}, | |||
| 68 | + | dst: markedFrame(3, 1, mark), | |||
| 69 | + | want: []renderer.Cell{mark, wide[2], mark}, | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | name: "right clipped leader remains untouched", | |||
| 73 | + | rect: domain.Rect{X: 0, Y: 0, Width: 3, Height: 1}, | |||
| 74 | + | dst: markedFrame(1, 1, mark), | |||
| 75 | + | want: []renderer.Cell{mark}, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "complete pair retains exact cells", | |||
| 79 | + | rect: domain.Rect{X: 1, Y: 0, Width: 3, Height: 1}, | |||
| 80 | + | dst: markedFrame(4, 1, mark), | |||
| 81 | + | want: []renderer.Cell{mark, wide[0], wide[1], wide[2]}, | |||
| 82 | + | }, | |||
| 83 | + | } | |||
| 84 | + | for _, tt := range tests { | |||
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 86 | + | BlitFrame(tt.dst, tt.rect, FrameView{Rows: [][]renderer.Cell{wide}, Width: len(wide), Height: 1}, VerticalAnchorTop) | |||
| 87 | + | assertRowEqual(t, tt.dst, 0, tt.want) | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestBlitFrameBoundsExtremeRectanglesToVisibleIntersection(t *testing.T) { | |||
| 93 | + | maxInt := int(^uint(0) >> 1) | |||
| 94 | + | minInt := -maxInt - 1 | |||
| 95 | + | style := renderer.Style{Foreground: 9} | |||
| 96 | + | src := FrameView{Rows: [][]renderer.Cell{{cell('a', style, false)}}, Width: 1, Height: 1} | |||
| 97 | + | mark := cell('!', renderer.Style{Background: 4}, false) | |||
| 98 | + | tests := []struct { | |||
| 99 | + | name string | |||
| 100 | + | rect domain.Rect | |||
| 101 | + | want []renderer.Cell | |||
| 102 | + | }{ | |||
| 103 | + | {"huge visible width copies only source span", domain.Rect{X: 0, Y: 0, Width: maxInt, Height: maxInt}, []renderer.Cell{src.Rows[0][0], mark}}, | |||
| 104 | + | {"max coordinate overflow is outside frame", domain.Rect{X: maxInt - 1, Y: maxInt - 1, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}}, | |||
| 105 | + | {"min coordinate overflow is outside frame", domain.Rect{X: minInt, Y: minInt, Width: maxInt, Height: maxInt}, []renderer.Cell{mark, mark}}, | |||
| 106 | + | } | |||
| 107 | + | for _, tt := range tests { | |||
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 109 | + | dst := markedFrame(2, 1, mark) | |||
| 110 | + | BlitFrame(dst, tt.rect, src, VerticalAnchorTop) | |||
| 111 | + | assertRowEqual(t, dst, 0, tt.want) | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestBlitFrameUsesLogicalDestinationRowsAndPreservesClippedSentinels(t *testing.T) { | |||
| 117 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |||
| 118 | + | dst := renderer.NewFrame(3, 3) | |||
| 119 | + | dst.ScrollUp(0, 2, 1) | |||
| 120 | + | for y := range dst.Height { | |||
| 121 | + | for x := range dst.Width { | |||
| 122 | + | dst.Set(x, y, mark) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | src := FrameView{Rows: [][]renderer.Cell{ | |||
| 126 | + | {cell('a', renderer.Style{Foreground: 1}, false), cell('b', renderer.Style{Foreground: 2}, false)}, | |||
| 127 | + | {cell('c', renderer.Style{Foreground: 4}, false), cell('d', renderer.Style{Foreground: 5}, false)}, | |||
| 128 | + | }, Width: 2, Height: 2} | |||
| 129 | + | ||||
| 130 | + | BlitFrame(dst, domain.Rect{X: -1, Y: 1, Width: 3, Height: 2}, src, VerticalAnchorTop) | |||
| 131 | + | ||||
| 132 | + | assertRowEqual(t, dst, 0, []renderer.Cell{mark, mark, mark}) | |||
| 133 | + | assertRowEqual(t, dst, 1, []renderer.Cell{src.Rows[0][1], mark, mark}) | |||
| 134 | + | assertRowEqual(t, dst, 2, []renderer.Cell{src.Rows[1][1], mark, mark}) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestBlitFrameClipsEveryEdgeAndPreservesUntouchedSentinels(t *testing.T) { | |||
| 138 | + | mark := cell('!', renderer.Style{Foreground: 3}, false) | |||
| 139 | + | src := FrameView{Rows: [][]renderer.Cell{ | |||
| 140 | + | {cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), false), cell('c', renderer.DefaultStyle(), false)}, | |||
| 141 | + | {cell('d', renderer.DefaultStyle(), false), cell('e', renderer.DefaultStyle(), false), cell('f', renderer.DefaultStyle(), false)}, | |||
| 142 | + | {cell('g', renderer.DefaultStyle(), false), cell('h', renderer.DefaultStyle(), false), cell('i', renderer.DefaultStyle(), false)}, | |||
| 143 | + | }, Width: 3, Height: 3} | |||
| 144 | + | ||||
| 145 | + | leftTop := markedFrame(3, 3, mark) | |||
| 146 | + | BlitFrame(leftTop, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop) | |||
| 147 | + | assertRowEqual(t, leftTop, 0, []renderer.Cell{src.Rows[1][1], src.Rows[1][2], mark}) | |||
| 148 | + | assertRowEqual(t, leftTop, 1, []renderer.Cell{src.Rows[2][1], src.Rows[2][2], mark}) | |||
| 149 | + | assertRowEqual(t, leftTop, 2, []renderer.Cell{mark, mark, mark}) | |||
| 150 | + | ||||
| 151 | + | rightBottom := markedFrame(3, 3, mark) | |||
| 152 | + | BlitFrame(rightBottom, domain.Rect{X: 2, Y: 2, Width: 3, Height: 3}, src, VerticalAnchorTop) | |||
| 153 | + | assertRowEqual(t, rightBottom, 0, []renderer.Cell{mark, mark, mark}) | |||
| 154 | + | assertRowEqual(t, rightBottom, 1, []renderer.Cell{mark, mark, mark}) | |||
| 155 | + | assertRowEqual(t, rightBottom, 2, []renderer.Cell{mark, mark, src.Rows[0][0]}) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestBlitFrameInvalidNoOp(t *testing.T) { | |||
| 159 | + | dst := renderer.NewFrame(2, 2) | |||
| 160 | + | before := dst.Clone() | |||
| 161 | + | cases := []struct { | |||
| 162 | + | r domain.Rect | |||
| 163 | + | s FrameView | |||
| 164 | + | a VerticalAnchor | |||
| 165 | + | }{ | |||
| 166 | + | {domain.Rect{}, FrameView{}, VerticalAnchorTop}, | |||
| 167 | + | {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop}, | |||
| 168 | + | {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)}, | |||
| 169 | + | } | |||
| 170 | + | for _, tc := range cases { | |||
| 171 | + | BlitFrame(dst, tc.r, tc.s, tc.a) | |||
| 172 | + | } | |||
| 173 | + | if !framesEqual(dst, before) { | |||
| 174 | + | t.Fatal("invalid blit changed destination") | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func markedFrame(width, height int, mark renderer.Cell) renderer.Frame { | |||
| 179 | + | frame := renderer.NewFrame(width, height) | |||
| 180 | + | for y := range height { | |||
| 181 | + | for x := range width { | |||
| 182 | + | frame.Set(x, y, mark) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | return frame | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func assertRowEqual(t *testing.T, frame renderer.Frame, y int, want []renderer.Cell) { | |||
| 189 | + | t.Helper() | |||
| 190 | + | if len(want) != frame.Width { | |||
| 191 | + | t.Fatalf("want row length %d, frame width %d", len(want), frame.Width) | |||
| 192 | + | } | |||
| 193 | + | for x := range want { | |||
| 194 | + | if got := frame.At(x, y); !got.Equal(want[x]) { | |||
| 195 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, got, want[x]) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | } | |||
A internal/usecase/ui/separator.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // SeparatorOrientation selects the axis of a separator slice. | |
| 9 | + | type SeparatorOrientation uint8 | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | SeparatorHorizontal SeparatorOrientation = iota | |
| 13 | + | SeparatorVertical | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // DrawSeparator draws a styled, clipped separator slice. It contains no layout | |
| 17 | + | // or theme policy; callers provide both the rectangle and style. | |
| 18 | + | func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) { | |
| 19 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | var cell renderer.Cell | |
| 23 | + | switch orientation { | |
| 24 | + | case SeparatorHorizontal: | |
| 25 | + | cell = renderer.Cell{Rune: '─', Style: style} | |
| 26 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell) | |
| 27 | + | case SeparatorVertical: | |
| 28 | + | cell = renderer.Cell{Rune: '│', Style: style} | |
| 29 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell) | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // SeparatorOrientation selects the axis of a separator slice. | |||
| 9 | + | type SeparatorOrientation uint8 | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | SeparatorHorizontal SeparatorOrientation = iota | |||
| 13 | + | SeparatorVertical | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // DrawSeparator draws a styled, clipped separator slice. It contains no layout | |||
| 17 | + | // or theme policy; callers provide both the rectangle and style. | |||
| 18 | + | func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) { | |||
| 19 | + | if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 { | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | var cell renderer.Cell | |||
| 23 | + | switch orientation { | |||
| 24 | + | case SeparatorHorizontal: | |||
| 25 | + | cell = renderer.Cell{Rune: '─', Style: style} | |||
| 26 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell) | |||
| 27 | + | case SeparatorVertical: | |||
| 28 | + | cell = renderer.Cell{Rune: '│', Style: style} | |||
| 29 | + | FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell) | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/usecase/ui/separator_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDrawSeparator(t *testing.T) { | |
| 11 | + | style := renderer.DefaultStyle() | |
| 12 | + | style.Attrs = renderer.AttrDim | |
| 13 | + | cases := []struct { | |
| 14 | + | name string | |
| 15 | + | orientation SeparatorOrientation | |
| 16 | + | rune rune | |
| 17 | + | rect domain.Rect | |
| 18 | + | }{ | |
| 19 | + | {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}}, | |
| 20 | + | {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}}, | |
| 21 | + | } | |
| 22 | + | for _, tc := range cases { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |
| 24 | + | f := renderer.NewFrame(6, 6) | |
| 25 | + | DrawSeparator(f, tc.rect, tc.orientation, style) | |
| 26 | + | for y := 0; y < 6; y++ { | |
| 27 | + | for x := 0; x < 6; x++ { | |
| 28 | + | want := renderer.BlankCell() | |
| 29 | + | if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) || | |
| 30 | + | (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) { | |
| 31 | + | want = renderer.Cell{Rune: tc.rune, Style: style} | |
| 32 | + | } | |
| 33 | + | if !f.At(x, y).Equal(want) { | |
| 34 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |
| 43 | + | style := renderer.DefaultStyle() | |
| 44 | + | f := renderer.NewFrame(3, 3) | |
| 45 | + | for y := range f.Height { | |
| 46 | + | for x := range f.Width { | |
| 47 | + | f.Set(x, y, renderer.Cell{Rune: 'x', Style: style}) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style) | |
| 51 | + | for x := 0; x < 3; x++ { | |
| 52 | + | if f.At(x, 1).Rune != '─' { | |
| 53 | + | t.Fatalf("clipping failed at %d", x) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | before := f.Clone() | |
| 57 | + | for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} { | |
| 58 | + | DrawSeparator(f, rect, SeparatorHorizontal, style) | |
| 59 | + | } | |
| 60 | + | DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style) | |
| 61 | + | if !framesEqual(f, before) { | |
| 62 | + | t.Fatal("invalid rectangles/orientation changed frame") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestDrawSeparatorInvalidFrame(t *testing.T) { | |
| 67 | + | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | |
| 68 | + | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func framesEqual(a, b renderer.Frame) bool { | |
| 72 | + | if a.Width != b.Width || a.Height != b.Height { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | for y := 0; y < a.Height; y++ { | |
| 76 | + | for x := 0; x < a.Width; x++ { | |
| 77 | + | if !a.At(x, y).Equal(b.At(x, y)) { | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return true | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestDrawSeparator(t *testing.T) { | |||
| 11 | + | style := renderer.DefaultStyle() | |||
| 12 | + | style.Attrs = renderer.AttrDim | |||
| 13 | + | cases := []struct { | |||
| 14 | + | name string | |||
| 15 | + | orientation SeparatorOrientation | |||
| 16 | + | rune rune | |||
| 17 | + | rect domain.Rect | |||
| 18 | + | }{ | |||
| 19 | + | {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}}, | |||
| 20 | + | {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}}, | |||
| 21 | + | } | |||
| 22 | + | for _, tc := range cases { | |||
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 24 | + | f := renderer.NewFrame(6, 6) | |||
| 25 | + | DrawSeparator(f, tc.rect, tc.orientation, style) | |||
| 26 | + | for y := 0; y < 6; y++ { | |||
| 27 | + | for x := 0; x < 6; x++ { | |||
| 28 | + | want := renderer.BlankCell() | |||
| 29 | + | if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) || | |||
| 30 | + | (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) { | |||
| 31 | + | want = renderer.Cell{Rune: tc.rune, Style: style} | |||
| 32 | + | } | |||
| 33 | + | if !f.At(x, y).Equal(want) { | |||
| 34 | + | t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) { | |||
| 43 | + | style := renderer.DefaultStyle() | |||
| 44 | + | f := renderer.NewFrame(3, 3) | |||
| 45 | + | for y := range f.Height { | |||
| 46 | + | for x := range f.Width { | |||
| 47 | + | f.Set(x, y, renderer.Cell{Rune: 'x', Style: style}) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style) | |||
| 51 | + | for x := 0; x < 3; x++ { | |||
| 52 | + | if f.At(x, 1).Rune != '─' { | |||
| 53 | + | t.Fatalf("clipping failed at %d", x) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | before := f.Clone() | |||
| 57 | + | for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} { | |||
| 58 | + | DrawSeparator(f, rect, SeparatorHorizontal, style) | |||
| 59 | + | } | |||
| 60 | + | DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style) | |||
| 61 | + | if !framesEqual(f, before) { | |||
| 62 | + | t.Fatal("invalid rectangles/orientation changed frame") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestDrawSeparatorInvalidFrame(t *testing.T) { | |||
| 67 | + | f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)} | |||
| 68 | + | DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle()) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func framesEqual(a, b renderer.Frame) bool { | |||
| 72 | + | if a.Width != b.Width || a.Height != b.Height { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | for y := 0; y < a.Height; y++ { | |||
| 76 | + | for x := 0; x < a.Width; x++ { | |||
| 77 | + | if !a.At(x, y).Equal(b.At(x, y)) { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return true | |||
| 83 | + | } | |||