feat(daemon): wire command palette
- commit
- e431dffb1756248bd64a55c5024ac4746a422357
- author
- brice · · 2026-07-03 05:43 UTC
- committer
- brice · · 2026-07-03 07:35 UTC
- parent
- 0ede052
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +449 -188
M internal/usecase/daemon/client.go +29 -19
| @@ -35,30 +35,34 @@ import ( | |||
| 35 | 35 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 36 | 36 | "github.com/bnema/vev/internal/usecase/keys" | |
| 37 | 37 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 38 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 38 | 39 | "github.com/bnema/vev/internal/usecase/picker" | |
| 39 | 40 | "github.com/bnema/vev/pkg/renderer" | |
| 40 | 41 | ) | |
| 41 | 42 | ||
| 42 | 43 | type attachedClient struct { | |
| 43 | - | tr ports.Transport | |
| 44 | - | rend *renderer.Renderer | |
| 45 | - | size domain.Size | |
| 46 | - | keys *keys.Router | |
| 47 | - | sessMu sync.Mutex | |
| 48 | - | sess *session | |
| 49 | - | copyMu sync.Mutex | |
| 50 | - | copyMode *scopy.Mode | |
| 51 | - | copyPending []byte | |
| 52 | - | copyESC pendingByteTimer | |
| 53 | - | copyFeedback string | |
| 54 | - | pickerMu sync.Mutex | |
| 55 | - | picker *picker.Model | |
| 56 | - | pickerPreview *tab | |
| 57 | - | pickerPending []byte | |
| 58 | - | pickerESC pendingByteTimer | |
| 59 | - | mouseScan mouse.Scanner | |
| 60 | - | lastCursor cursorOut | |
| 61 | - | sendMu sync.Mutex | |
| 44 | + | tr ports.Transport | |
| 45 | + | rend *renderer.Renderer | |
| 46 | + | size domain.Size | |
| 47 | + | keys *keys.Router | |
| 48 | + | sessMu sync.Mutex | |
| 49 | + | sess *session | |
| 50 | + | copyMu sync.Mutex | |
| 51 | + | copyMode *scopy.Mode | |
| 52 | + | copyPending []byte | |
| 53 | + | copyESC pendingByteTimer | |
| 54 | + | copyFeedback string | |
| 55 | + | pickerMu sync.Mutex | |
| 56 | + | picker *picker.Model | |
| 57 | + | pickerPreview *tab | |
| 58 | + | pickerPending []byte | |
| 59 | + | pickerESC pendingByteTimer | |
| 60 | + | paletteMu sync.Mutex | |
| 61 | + | palette *palette.Model | |
| 62 | + | palettePending []byte | |
| 63 | + | mouseScan mouse.Scanner | |
| 64 | + | lastCursor cursorOut | |
| 65 | + | sendMu sync.Mutex | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | type cursorOut struct { | |
| @@ -123,6 +127,12 @@ func (ac *attachedClient) pickerActive() bool { | |||
| 123 | 127 | return ac.picker != nil | |
| 124 | 128 | } | |
| 125 | 129 | ||
| 130 | + | func (ac *attachedClient) paletteActive() bool { | |
| 131 | + | ac.paletteMu.Lock() | |
| 132 | + | defer ac.paletteMu.Unlock() | |
| 133 | + | return ac.palette != nil | |
| 134 | + | } | |
| 135 | + | ||
| 126 | 136 | // send serialises a frame onto the client's transport. | |
| 127 | 137 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 128 | 138 | ac.sendMu.Lock() | |
| @@ -35,30 +35,34 @@ import ( | |||||
| 35 | scopy "github.com/bnema/vev/internal/usecase/copy" | 35 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 36 | "github.com/bnema/vev/internal/usecase/keys" | 36 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 37 | "github.com/bnema/vev/internal/usecase/mouse" | 37 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 38 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 38 | "github.com/bnema/vev/internal/usecase/picker" | 39 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 39 | "github.com/bnema/vev/pkg/renderer" | 40 | "github.com/bnema/vev/pkg/renderer" | ||
| 40 | ) | 41 | ) | ||
| 41 | 42 | ||||
| 42 | type attachedClient struct { | 43 | type attachedClient struct { | ||
| 43 | - | tr ports.Transport | 44 | + | tr ports.Transport |
| 44 | - | rend *renderer.Renderer | 45 | + | rend *renderer.Renderer |
| 45 | - | size domain.Size | 46 | + | size domain.Size |
| 46 | - | keys *keys.Router | 47 | + | keys *keys.Router |
| 47 | - | sessMu sync.Mutex | 48 | + | sessMu sync.Mutex |
| 48 | - | sess *session | 49 | + | sess *session |
| 49 | - | copyMu sync.Mutex | 50 | + | copyMu sync.Mutex |
| 50 | - | copyMode *scopy.Mode | 51 | + | copyMode *scopy.Mode |
| 51 | - | copyPending []byte | 52 | + | copyPending []byte |
| 52 | - | copyESC pendingByteTimer | 53 | + | copyESC pendingByteTimer |
| 53 | - | copyFeedback string | 54 | + | copyFeedback string |
| 54 | - | pickerMu sync.Mutex | 55 | + | pickerMu sync.Mutex |
| 55 | - | picker *picker.Model | 56 | + | picker *picker.Model |
| 56 | - | pickerPreview *tab | 57 | + | pickerPreview *tab |
| 57 | - | pickerPending []byte | 58 | + | pickerPending []byte |
| 58 | - | pickerESC pendingByteTimer | 59 | + | pickerESC pendingByteTimer |
| 59 | - | mouseScan mouse.Scanner | 60 | + | paletteMu sync.Mutex |
| 60 | - | lastCursor cursorOut | 61 | + | palette *palette.Model |
| 61 | - | sendMu sync.Mutex | 62 | + | palettePending []byte |
| 63 | + | mouseScan mouse.Scanner | |||
| 64 | + | lastCursor cursorOut | |||
| 65 | + | sendMu sync.Mutex | |||
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | type cursorOut struct { | 68 | type cursorOut struct { | ||
| @@ -123,6 +127,12 @@ func (ac *attachedClient) pickerActive() bool { | |||||
| 123 | return ac.picker != nil | 127 | return ac.picker != nil | ||
| 124 | } | 128 | } | ||
| 125 | 129 | ||||
| 130 | + | func (ac *attachedClient) paletteActive() bool { | |||
| 131 | + | ac.paletteMu.Lock() | |||
| 132 | + | defer ac.paletteMu.Unlock() | |||
| 133 | + | return ac.palette != nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 126 | // send serialises a frame onto the client's transport. | 136 | // send serialises a frame onto the client's transport. | ||
| 127 | func (ac *attachedClient) send(f ports.Frame) error { | 137 | func (ac *attachedClient) send(f ports.Frame) error { | ||
| 128 | ac.sendMu.Lock() | 138 | ac.sendMu.Lock() | ||
M internal/usecase/daemon/copymode_test.go +7 -3
| @@ -25,14 +25,16 @@ import ( | |||
| 25 | 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 26 | 26 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 27 | 27 | ||
| 28 | - | func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |
| 28 | + | func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |
| 29 | 29 | writes := make(chan []byte, 1) | |
| 30 | 30 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 31 | 31 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 32 | 32 | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 33 | 33 | sess.tabs[0].screen.Write([]byte("live")) | |
| 34 | 34 | ||
| 35 | - | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 35 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 36 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 37 | + | d.handleInput(sess, ac, []byte("CPY\r")) | |
| 36 | 38 | ||
| 37 | 39 | if ac.copyMode == nil { | |
| 38 | 40 | t.Fatal("scrollback mode not entered") | |
| @@ -153,7 +155,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 153 | 155 | require.NotNil(t, sess) | |
| 154 | 156 | ac := sess.client | |
| 155 | 157 | require.NotNil(t, ac) | |
| 156 | - | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 158 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 159 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 160 | + | d.handleInput(sess, ac, []byte("CPY\r")) | |
| 157 | 161 | awaitFrame(t, sends, ports.MsgOutput) | |
| 158 | 162 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |
| 159 | 163 | ||
| @@ -25,14 +25,16 @@ import ( | |||||
| 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 26 | // blocks in its debounce loop until the session context is cancelled. Used by | 26 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 27 | 27 | ||||
| 28 | - | func TestScrollbackModeAltUInterceptsAndDoesNotForward(t *testing.T) { | 28 | + | func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { |
| 29 | writes := make(chan []byte, 1) | 29 | writes := make(chan []byte, 1) | ||
| 30 | p, _ := newBlockingPTYWithWrites(t, writes) | 30 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 31 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 31 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 32 | sess.tabs[0].scrollback = scopy.NewScrollback(4) | 32 | sess.tabs[0].scrollback = scopy.NewScrollback(4) | ||
| 33 | sess.tabs[0].screen.Write([]byte("live")) | 33 | sess.tabs[0].screen.Write([]byte("live")) | ||
| 34 | 34 | ||||
| 35 | - | d.handleInput(sess, ac, []byte("\x1bu")) | 35 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 36 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 37 | + | d.handleInput(sess, ac, []byte("CPY\r")) | |||
| 36 | 38 | ||||
| 37 | if ac.copyMode == nil { | 39 | if ac.copyMode == nil { | ||
| 38 | t.Fatal("scrollback mode not entered") | 40 | t.Fatal("scrollback mode not entered") | ||
| @@ -153,7 +155,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 153 | require.NotNil(t, sess) | 155 | require.NotNil(t, sess) | ||
| 154 | ac := sess.client | 156 | ac := sess.client | ||
| 155 | require.NotNil(t, ac) | 157 | require.NotNil(t, ac) | ||
| 156 | - | d.handleInput(sess, ac, []byte("\x1bu")) | 158 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 159 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 160 | + | d.handleInput(sess, ac, []byte("CPY\r")) | |||
| 157 | awaitFrame(t, sends, ports.MsgOutput) | 161 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 158 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | 162 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | ||
| 159 | 163 | ||||
M internal/usecase/daemon/input.go +7 -29
| @@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 34 | 34 | ac.mouseScan.Scan(data, | |
| 35 | 35 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 36 | 36 | func(b []byte) { | |
| 37 | + | if ac.paletteActive() { | |
| 38 | + | d.handlePaletteInput(ac, b) | |
| 39 | + | return | |
| 40 | + | } | |
| 37 | 41 | if ac.pickerActive() { | |
| 38 | 42 | d.handlePickerInput(ac, b) | |
| 39 | 43 | return | |
| @@ -48,7 +52,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | 54 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |
| 51 | - | if ac.pickerActive() { | |
| 55 | + | if ac.paletteActive() || ac.pickerActive() { | |
| 52 | 56 | return | |
| 53 | 57 | } | |
| 54 | 58 | sess := ac.currentSession() | |
| @@ -124,34 +128,8 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 124 | 128 | return | |
| 125 | 129 | } | |
| 126 | 130 | switch action { | |
| 127 | - | case keys.ActionCreateTab: | |
| 128 | - | if err := h.d.createTab(sess, h.ac.size); err != nil { | |
| 129 | - | h.d.log.Error("create tab failed", "err", err, "session", sess.name) | |
| 130 | - | return | |
| 131 | - | } | |
| 132 | - | h.d.paint(sess, h.ac, true) | |
| 133 | - | case keys.ActionNextTab: | |
| 134 | - | if sess.switchRelative(1) { | |
| 135 | - | h.d.paint(sess, h.ac, true) | |
| 136 | - | } | |
| 137 | - | case keys.ActionPreviousTab: | |
| 138 | - | if sess.switchRelative(-1) { | |
| 139 | - | h.d.paint(sess, h.ac, true) | |
| 140 | - | } | |
| 141 | - | case keys.ActionDetach: | |
| 142 | - | h.d.clientGone(sess, h.ac, true) | |
| 143 | - | case keys.ActionCloseTab: | |
| 144 | - | tb := sess.activeTab() | |
| 145 | - | if tb != nil { | |
| 146 | - | h.d.closeTab(sess, tb, true) | |
| 147 | - | } | |
| 148 | - | case keys.ActionCopyMode: | |
| 149 | - | h.d.enterCopyMode(sess, h.ac) | |
| 150 | - | case keys.ActionRenameSession: | |
| 151 | - | sess.promoteEphemeral() | |
| 152 | - | h.d.paint(sess, h.ac, true) | |
| 153 | - | case keys.ActionOpenPicker: | |
| 154 | - | h.d.enterPicker(sess, h.ac) | |
| 131 | + | case keys.ActionOpenPalette: | |
| 132 | + | h.d.enterPalette(sess, h.ac) | |
| 155 | 133 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 156 | 134 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 157 | 135 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| @@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||||
| 34 | ac.mouseScan.Scan(data, | 34 | ac.mouseScan.Scan(data, | ||
| 35 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | 35 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | ||
| 36 | func(b []byte) { | 36 | func(b []byte) { | ||
| 37 | + | if ac.paletteActive() { | |||
| 38 | + | d.handlePaletteInput(ac, b) | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 37 | if ac.pickerActive() { | 41 | if ac.pickerActive() { | ||
| 38 | d.handlePickerInput(ac, b) | 42 | d.handlePickerInput(ac, b) | ||
| 39 | return | 43 | return | ||
| @@ -48,7 +52,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||||
| 48 | } | 52 | } | ||
| 49 | 53 | ||||
| 50 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | 54 | func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | ||
| 51 | - | if ac.pickerActive() { | 55 | + | if ac.paletteActive() || ac.pickerActive() { |
| 52 | return | 56 | return | ||
| 53 | } | 57 | } | ||
| 54 | sess := ac.currentSession() | 58 | sess := ac.currentSession() | ||
| @@ -124,34 +128,8 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 124 | return | 128 | return | ||
| 125 | } | 129 | } | ||
| 126 | switch action { | 130 | switch action { | ||
| 127 | - | case keys.ActionCreateTab: | 131 | + | case keys.ActionOpenPalette: |
| 128 | - | if err := h.d.createTab(sess, h.ac.size); err != nil { | 132 | + | h.d.enterPalette(sess, h.ac) |
| 129 | - | h.d.log.Error("create tab failed", "err", err, "session", sess.name) | |||
| 130 | - | return | |||
| 131 | - | } | |||
| 132 | - | h.d.paint(sess, h.ac, true) | |||
| 133 | - | case keys.ActionNextTab: | |||
| 134 | - | if sess.switchRelative(1) { | |||
| 135 | - | h.d.paint(sess, h.ac, true) | |||
| 136 | - | } | |||
| 137 | - | case keys.ActionPreviousTab: | |||
| 138 | - | if sess.switchRelative(-1) { | |||
| 139 | - | h.d.paint(sess, h.ac, true) | |||
| 140 | - | } | |||
| 141 | - | case keys.ActionDetach: | |||
| 142 | - | h.d.clientGone(sess, h.ac, true) | |||
| 143 | - | case keys.ActionCloseTab: | |||
| 144 | - | tb := sess.activeTab() | |||
| 145 | - | if tb != nil { | |||
| 146 | - | h.d.closeTab(sess, tb, true) | |||
| 147 | - | } | |||
| 148 | - | case keys.ActionCopyMode: | |||
| 149 | - | h.d.enterCopyMode(sess, h.ac) | |||
| 150 | - | case keys.ActionRenameSession: | |||
| 151 | - | sess.promoteEphemeral() | |||
| 152 | - | h.d.paint(sess, h.ac, true) | |||
| 153 | - | case keys.ActionOpenPicker: | |||
| 154 | - | h.d.enterPicker(sess, h.ac) | |||
| 155 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 133 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 156 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 134 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 157 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 135 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +25 -34
| @@ -26,8 +26,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 26 | 26 | d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{}) | |
| 27 | 27 | tr, sends, releaseConn := newConn(t, | |
| 28 | 28 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | |
| 29 | - | frameInput([]byte("\x1bc")), | |
| 30 | - | frameInput([]byte("\x1bc")), | |
| 29 | + | frameInput([]byte("\x1b ")), | |
| 30 | + | frameInput([]byte("CNT\r")), | |
| 31 | + | frameInput([]byte("\x1b ")), | |
| 32 | + | frameInput([]byte("CNT\r")), | |
| 31 | 33 | frameInput([]byte("\x1b1")), | |
| 32 | 34 | frameInput([]byte("A")), | |
| 33 | 35 | frameInput([]byte("\x1b2")), | |
| @@ -60,43 +62,28 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 60 | 62 | d.sessWg.Wait() | |
| 61 | 63 | } | |
| 62 | 64 | ||
| 63 | - | func TestAltCCreatesSecondActiveTab(t *testing.T) { | |
| 64 | - | p1, releasePTY1 := newBlockingPTY(t) | |
| 65 | - | p2, releasePTY2 := newBlockingPTY(t) | |
| 66 | - | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 67 | - | tr, sends, releaseConn := newConn(t, | |
| 68 | - | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | |
| 69 | - | frameInput([]byte("\x1bc")), | |
| 70 | - | ) | |
| 71 | - | ||
| 72 | - | var hg sync.WaitGroup | |
| 73 | - | hg.Go(func() { d.handleConn(tr) }) | |
| 74 | - | awaitFrame(t, sends, ports.MsgWelcome) | |
| 75 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 65 | + | func TestAltCForwardsToPTY(t *testing.T) { | |
| 66 | + | writes := make(chan []byte, 2) | |
| 67 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 68 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 76 | 69 | ||
| 77 | - | require.Eventually(t, func() bool { | |
| 78 | - | sessions := listSessions(t, d) | |
| 79 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2 | |
| 80 | - | }, 2*time.Second, 5*time.Millisecond) | |
| 70 | + | d.handleInput(sess, ac, []byte("\x1bc")) | |
| 81 | 71 | ||
| 82 | - | releaseConn() | |
| 83 | - | releasePTY1() | |
| 84 | - | releasePTY2() | |
| 85 | - | hg.Wait() | |
| 86 | - | d.sessWg.Wait() | |
| 72 | + | require.Equal(t, []byte("\x1bc"), <-writes) | |
| 73 | + | releasePTY() | |
| 87 | 74 | } | |
| 88 | 75 | ||
| 89 | - | func TestAltNextPreviousSwitchActiveTab(t *testing.T) { | |
| 76 | + | func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |
| 90 | 77 | cases := []struct { | |
| 91 | 78 | name string | |
| 92 | 79 | start int | |
| 93 | - | input []byte | |
| 80 | + | query []byte | |
| 94 | 81 | wantIndex int | |
| 95 | 82 | }{ | |
| 96 | - | {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1}, | |
| 97 | - | {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0}, | |
| 98 | - | {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1}, | |
| 99 | - | {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2}, | |
| 83 | + | {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1}, | |
| 84 | + | {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0}, | |
| 85 | + | {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1}, | |
| 86 | + | {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2}, | |
| 100 | 87 | } | |
| 101 | 88 | ||
| 102 | 89 | for _, tc := range cases { | |
| @@ -109,7 +96,8 @@ func TestAltNextPreviousSwitchActiveTab(t *testing.T) { | |||
| 109 | 96 | }() | |
| 110 | 97 | sess.active = tc.start | |
| 111 | 98 | ||
| 112 | - | d.handleInput(sess, ac, tc.input) | |
| 99 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 100 | + | d.handleInput(sess, ac, tc.query) | |
| 113 | 101 | ||
| 114 | 102 | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) | |
| 115 | 103 | }) | |
| @@ -124,7 +112,8 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||
| 124 | 112 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3) | |
| 125 | 113 | sess.active = 1 | |
| 126 | 114 | ||
| 127 | - | d.handleInput(sess, ac, []byte("\x1bx")) | |
| 115 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 116 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |
| 128 | 117 | ||
| 129 | 118 | require.Equal(t, 1, sessionCount(d)) | |
| 130 | 119 | require.Len(t, sess.tabs, 2) | |
| @@ -142,7 +131,8 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 142 | 131 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 143 | 132 | defer releases[0]() | |
| 144 | 133 | ||
| 145 | - | d.handleInput(sess, ac, []byte("\x1bd")) | |
| 134 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 135 | + | d.handleInput(sess, ac, []byte("DET\r")) | |
| 146 | 136 | ||
| 147 | 137 | require.Nil(t, sess.client) | |
| 148 | 138 | f := awaitFrame(t, sends, ports.MsgDetached) | |
| @@ -157,7 +147,8 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||
| 157 | 147 | sess.ephemeral = true | |
| 158 | 148 | sess.name = "0" | |
| 159 | 149 | ||
| 160 | - | d.handleInput(sess, ac, []byte("\x1br")) | |
| 150 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 151 | + | d.handleInput(sess, ac, []byte("RNS\r")) | |
| 161 | 152 | ||
| 162 | 153 | require.False(t, sess.ephemeral) | |
| 163 | 154 | require.Equal(t, "0", sess.name) | |
| @@ -26,8 +26,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 26 | d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{}) | 26 | d := newTestDaemon(t, newFactorySeq(t, p1, p2, p3), stubClock{}) | ||
| 27 | tr, sends, releaseConn := newConn(t, | 27 | tr, sends, releaseConn := newConn(t, | ||
| 28 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | 28 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | ||
| 29 | - | frameInput([]byte("\x1bc")), | 29 | + | frameInput([]byte("\x1b ")), |
| 30 | - | frameInput([]byte("\x1bc")), | 30 | + | frameInput([]byte("CNT\r")), |
| 31 | + | frameInput([]byte("\x1b ")), | |||
| 32 | + | frameInput([]byte("CNT\r")), | |||
| 31 | frameInput([]byte("\x1b1")), | 33 | frameInput([]byte("\x1b1")), | ||
| 32 | frameInput([]byte("A")), | 34 | frameInput([]byte("A")), | ||
| 33 | frameInput([]byte("\x1b2")), | 35 | frameInput([]byte("\x1b2")), | ||
| @@ -60,43 +62,28 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 60 | d.sessWg.Wait() | 62 | d.sessWg.Wait() | ||
| 61 | } | 63 | } | ||
| 62 | 64 | ||||
| 63 | - | func TestAltCCreatesSecondActiveTab(t *testing.T) { | 65 | + | func TestAltCForwardsToPTY(t *testing.T) { |
| 64 | - | p1, releasePTY1 := newBlockingPTY(t) | 66 | + | writes := make(chan []byte, 2) |
| 65 | - | p2, releasePTY2 := newBlockingPTY(t) | 67 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) |
| 66 | - | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | 68 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) |
| 67 | - | tr, sends, releaseConn := newConn(t, | |||
| 68 | - | mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}), | |||
| 69 | - | frameInput([]byte("\x1bc")), | |||
| 70 | - | ) | |||
| 71 | - | ||||
| 72 | - | var hg sync.WaitGroup | |||
| 73 | - | hg.Go(func() { d.handleConn(tr) }) | |||
| 74 | - | awaitFrame(t, sends, ports.MsgWelcome) | |||
| 75 | - | awaitFrame(t, sends, ports.MsgOutput) | |||
| 76 | 69 | ||||
| 77 | - | require.Eventually(t, func() bool { | 70 | + | d.handleInput(sess, ac, []byte("\x1bc")) |
| 78 | - | sessions := listSessions(t, d) | |||
| 79 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2 | |||
| 80 | - | }, 2*time.Second, 5*time.Millisecond) | |||
| 81 | 71 | ||||
| 82 | - | releaseConn() | 72 | + | require.Equal(t, []byte("\x1bc"), <-writes) |
| 83 | - | releasePTY1() | 73 | + | releasePTY() |
| 84 | - | releasePTY2() | |||
| 85 | - | hg.Wait() | |||
| 86 | - | d.sessWg.Wait() | |||
| 87 | } | 74 | } | ||
| 88 | 75 | ||||
| 89 | - | func TestAltNextPreviousSwitchActiveTab(t *testing.T) { | 76 | + | func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { |
| 90 | cases := []struct { | 77 | cases := []struct { | ||
| 91 | name string | 78 | name string | ||
| 92 | start int | 79 | start int | ||
| 93 | - | input []byte | 80 | + | query []byte |
| 94 | wantIndex int | 81 | wantIndex int | ||
| 95 | }{ | 82 | }{ | ||
| 96 | - | {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1}, | 83 | + | {name: "next advances", start: 0, query: []byte("NXT\r"), wantIndex: 1}, |
| 97 | - | {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0}, | 84 | + | {name: "next wraps", start: 2, query: []byte("NXT\r"), wantIndex: 0}, |
| 98 | - | {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1}, | 85 | + | {name: "previous moves back", start: 2, query: []byte("PVT\r"), wantIndex: 1}, |
| 99 | - | {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2}, | 86 | + | {name: "previous wraps", start: 0, query: []byte("PVT\r"), wantIndex: 2}, |
| 100 | } | 87 | } | ||
| 101 | 88 | ||||
| 102 | for _, tc := range cases { | 89 | for _, tc := range cases { | ||
| @@ -109,7 +96,8 @@ func TestAltNextPreviousSwitchActiveTab(t *testing.T) { | |||||
| 109 | }() | 96 | }() | ||
| 110 | sess.active = tc.start | 97 | sess.active = tc.start | ||
| 111 | 98 | ||||
| 112 | - | d.handleInput(sess, ac, tc.input) | 99 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 100 | + | d.handleInput(sess, ac, tc.query) | |||
| 113 | 101 | ||||
| 114 | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) | 102 | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) | ||
| 115 | }) | 103 | }) | ||
| @@ -124,7 +112,8 @@ func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |||||
| 124 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3) | 112 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3) | ||
| 125 | sess.active = 1 | 113 | sess.active = 1 | ||
| 126 | 114 | ||||
| 127 | - | d.handleInput(sess, ac, []byte("\x1bx")) | 115 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 116 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |||
| 128 | 117 | ||||
| 129 | require.Equal(t, 1, sessionCount(d)) | 118 | require.Equal(t, 1, sessionCount(d)) | ||
| 130 | require.Len(t, sess.tabs, 2) | 119 | require.Len(t, sess.tabs, 2) | ||
| @@ -142,7 +131,8 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||||
| 142 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 131 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 143 | defer releases[0]() | 132 | defer releases[0]() | ||
| 144 | 133 | ||||
| 145 | - | d.handleInput(sess, ac, []byte("\x1bd")) | 134 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 135 | + | d.handleInput(sess, ac, []byte("DET\r")) | |||
| 146 | 136 | ||||
| 147 | require.Nil(t, sess.client) | 137 | require.Nil(t, sess.client) | ||
| 148 | f := awaitFrame(t, sends, ports.MsgDetached) | 138 | f := awaitFrame(t, sends, ports.MsgDetached) | ||
| @@ -157,7 +147,8 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||||
| 157 | sess.ephemeral = true | 147 | sess.ephemeral = true | ||
| 158 | sess.name = "0" | 148 | sess.name = "0" | ||
| 159 | 149 | ||||
| 160 | - | d.handleInput(sess, ac, []byte("\x1br")) | 150 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 151 | + | d.handleInput(sess, ac, []byte("RNS\r")) | |||
| 161 | 152 | ||||
| 162 | require.False(t, sess.ephemeral) | 153 | require.False(t, sess.ephemeral) | ||
| 163 | require.Equal(t, "0", sess.name) | 154 | require.Equal(t, "0", sess.name) | ||
A internal/usecase/daemon/palette.go +206 -0
| @@ -0,0 +1,206 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "unicode" | |
| 5 | + | "unicode/utf8" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var paletteModal = ui.Modal{WidthPct: 80, HeightPct: 40, MinWidth: 32, MinHeight: 6, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | |
| 15 | + | ||
| 16 | + | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |
| 17 | + | d.closePalette(ac) | |
| 18 | + | ac.paletteMu.Lock() | |
| 19 | + | ac.palette = palette.NewRegistry() | |
| 20 | + | ac.palettePending = nil | |
| 21 | + | ac.paletteMu.Unlock() | |
| 22 | + | d.paint(sess, ac, true) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (d *Daemon) closePalette(ac *attachedClient) { | |
| 26 | + | ac.paletteMu.Lock() | |
| 27 | + | ac.palette = nil | |
| 28 | + | ac.palettePending = nil | |
| 29 | + | ac.paletteMu.Unlock() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |
| 33 | + | sess := ac.currentSession() | |
| 34 | + | if sess == nil { | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | ac.paletteMu.Lock() | |
| 39 | + | if ac.palette == nil { | |
| 40 | + | ac.palettePending = nil | |
| 41 | + | ac.paletteMu.Unlock() | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | if len(ac.palettePending) > 0 { | |
| 45 | + | combined := make([]byte, 0, len(ac.palettePending)+len(data)) | |
| 46 | + | combined = append(combined, ac.palettePending...) | |
| 47 | + | combined = append(combined, data...) | |
| 48 | + | data = combined | |
| 49 | + | ac.palettePending = nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | changed := false | |
| 53 | + | exit := false | |
| 54 | + | run := false | |
| 55 | + | var cmd command.Command | |
| 56 | + | var ok bool | |
| 57 | + | ||
| 58 | + | for i := 0; i < len(data); { | |
| 59 | + | switch data[i] { | |
| 60 | + | case 0x1b: | |
| 61 | + | consumed, routed := routePaletteEscape(ac.palette, data[i:]) | |
| 62 | + | if routed { | |
| 63 | + | i += consumed | |
| 64 | + | changed = true | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | if isPaletteEscapePrefix(data[i:]) { | |
| 68 | + | ac.palettePending = append(ac.palettePending[:0], data[i:]...) | |
| 69 | + | i = len(data) | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | exit = true | |
| 73 | + | i++ | |
| 74 | + | case 0x03: | |
| 75 | + | exit = true | |
| 76 | + | i++ | |
| 77 | + | case '\r', '\n': | |
| 78 | + | cmd, ok = ac.palette.Selected() | |
| 79 | + | if ok { | |
| 80 | + | run = true | |
| 81 | + | exit = true | |
| 82 | + | } | |
| 83 | + | i++ | |
| 84 | + | case 0x7f, 0x08: | |
| 85 | + | ac.palette.Backspace() | |
| 86 | + | changed = true | |
| 87 | + | i++ | |
| 88 | + | default: | |
| 89 | + | r, size := utf8.DecodeRune(data[i:]) | |
| 90 | + | if r == utf8.RuneError { | |
| 91 | + | if !utf8.FullRune(data[i:]) { | |
| 92 | + | ac.palettePending = append(ac.palettePending[:0], data[i:]...) | |
| 93 | + | i = len(data) | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | i++ | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | if !unicode.IsControl(r) { | |
| 100 | + | ac.palette.Insert(r) | |
| 101 | + | changed = true | |
| 102 | + | } | |
| 103 | + | i += size | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | if exit { | |
| 107 | + | ac.palette = nil | |
| 108 | + | ac.palettePending = nil | |
| 109 | + | } | |
| 110 | + | ac.paletteMu.Unlock() | |
| 111 | + | ||
| 112 | + | if run && ok { | |
| 113 | + | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | |
| 114 | + | d.log.Error("palette command failed", "err", err, "session", sess.name, "command", cmd.Code) | |
| 115 | + | } | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | if exit || changed { | |
| 119 | + | d.paint(sess, ac, true) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func routePaletteEscape(m *palette.Model, data []byte) (int, bool) { | |
| 124 | + | if len(data) >= 3 && data[1] == '[' { | |
| 125 | + | switch data[2] { | |
| 126 | + | case 'A': | |
| 127 | + | m.Up() | |
| 128 | + | return 3, true | |
| 129 | + | case 'B': | |
| 130 | + | m.Down() | |
| 131 | + | return 3, true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return 0, false | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func isPaletteEscapePrefix(data []byte) bool { | |
| 138 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | type paletteExec struct { | |
| 142 | + | d *Daemon | |
| 143 | + | sess *session | |
| 144 | + | ac *attachedClient | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (e paletteExec) CreateTab() error { | |
| 148 | + | if err := e.d.createTab(e.sess, e.ac.size); err != nil { | |
| 149 | + | return err | |
| 150 | + | } | |
| 151 | + | e.d.paint(e.sess, e.ac, true) | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (e paletteExec) CloseTab() error { | |
| 156 | + | if tb := e.sess.activeTab(); tb != nil { | |
| 157 | + | e.d.closeTab(e.sess, tb, true) | |
| 158 | + | } | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (e paletteExec) NextTab() error { | |
| 163 | + | if e.sess.switchRelative(1) { | |
| 164 | + | e.d.paint(e.sess, e.ac, true) | |
| 165 | + | } | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (e paletteExec) PrevTab() error { | |
| 170 | + | if e.sess.switchRelative(-1) { | |
| 171 | + | e.d.paint(e.sess, e.ac, true) | |
| 172 | + | } | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (e paletteExec) Detach() error { | |
| 177 | + | e.d.clientGone(e.sess, e.ac, true) | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (e paletteExec) EnterCopyMode() error { | |
| 182 | + | e.d.enterCopyMode(e.sess, e.ac) | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (e paletteExec) RenameSession() error { | |
| 187 | + | e.sess.promoteEphemeral() | |
| 188 | + | e.d.paint(e.sess, e.ac, true) | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (e paletteExec) OpenSessionPicker() error { | |
| 193 | + | e.d.enterPicker(e.sess, e.ac) | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 198 | + | inner := paletteModal.Composite(base, renderer.DefaultStyle()) | |
| 199 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 200 | + | for y := range min(inner.Height, modalFrame.Height) { | |
| 201 | + | for x := range min(inner.Width, modalFrame.Width) { | |
| 202 | + | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return base, []renderer.Damage{renderer.FullRedraw()} | |
| 206 | + | } | |
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "unicode" | |||
| 5 | + | "unicode/utf8" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var paletteModal = ui.Modal{WidthPct: 80, HeightPct: 40, MinWidth: 32, MinHeight: 6, Title: " Commands ", Anchor: ui.AnchorBottom, BottomMargin: 1} | |||
| 15 | + | ||||
| 16 | + | func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 17 | + | d.closePalette(ac) | |||
| 18 | + | ac.paletteMu.Lock() | |||
| 19 | + | ac.palette = palette.NewRegistry() | |||
| 20 | + | ac.palettePending = nil | |||
| 21 | + | ac.paletteMu.Unlock() | |||
| 22 | + | d.paint(sess, ac, true) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (d *Daemon) closePalette(ac *attachedClient) { | |||
| 26 | + | ac.paletteMu.Lock() | |||
| 27 | + | ac.palette = nil | |||
| 28 | + | ac.palettePending = nil | |||
| 29 | + | ac.paletteMu.Unlock() | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 33 | + | sess := ac.currentSession() | |||
| 34 | + | if sess == nil { | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | ac.paletteMu.Lock() | |||
| 39 | + | if ac.palette == nil { | |||
| 40 | + | ac.palettePending = nil | |||
| 41 | + | ac.paletteMu.Unlock() | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | if len(ac.palettePending) > 0 { | |||
| 45 | + | combined := make([]byte, 0, len(ac.palettePending)+len(data)) | |||
| 46 | + | combined = append(combined, ac.palettePending...) | |||
| 47 | + | combined = append(combined, data...) | |||
| 48 | + | data = combined | |||
| 49 | + | ac.palettePending = nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | changed := false | |||
| 53 | + | exit := false | |||
| 54 | + | run := false | |||
| 55 | + | var cmd command.Command | |||
| 56 | + | var ok bool | |||
| 57 | + | ||||
| 58 | + | for i := 0; i < len(data); { | |||
| 59 | + | switch data[i] { | |||
| 60 | + | case 0x1b: | |||
| 61 | + | consumed, routed := routePaletteEscape(ac.palette, data[i:]) | |||
| 62 | + | if routed { | |||
| 63 | + | i += consumed | |||
| 64 | + | changed = true | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | if isPaletteEscapePrefix(data[i:]) { | |||
| 68 | + | ac.palettePending = append(ac.palettePending[:0], data[i:]...) | |||
| 69 | + | i = len(data) | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | exit = true | |||
| 73 | + | i++ | |||
| 74 | + | case 0x03: | |||
| 75 | + | exit = true | |||
| 76 | + | i++ | |||
| 77 | + | case '\r', '\n': | |||
| 78 | + | cmd, ok = ac.palette.Selected() | |||
| 79 | + | if ok { | |||
| 80 | + | run = true | |||
| 81 | + | exit = true | |||
| 82 | + | } | |||
| 83 | + | i++ | |||
| 84 | + | case 0x7f, 0x08: | |||
| 85 | + | ac.palette.Backspace() | |||
| 86 | + | changed = true | |||
| 87 | + | i++ | |||
| 88 | + | default: | |||
| 89 | + | r, size := utf8.DecodeRune(data[i:]) | |||
| 90 | + | if r == utf8.RuneError { | |||
| 91 | + | if !utf8.FullRune(data[i:]) { | |||
| 92 | + | ac.palettePending = append(ac.palettePending[:0], data[i:]...) | |||
| 93 | + | i = len(data) | |||
| 94 | + | continue | |||
| 95 | + | } | |||
| 96 | + | i++ | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | if !unicode.IsControl(r) { | |||
| 100 | + | ac.palette.Insert(r) | |||
| 101 | + | changed = true | |||
| 102 | + | } | |||
| 103 | + | i += size | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | if exit { | |||
| 107 | + | ac.palette = nil | |||
| 108 | + | ac.palettePending = nil | |||
| 109 | + | } | |||
| 110 | + | ac.paletteMu.Unlock() | |||
| 111 | + | ||||
| 112 | + | if run && ok { | |||
| 113 | + | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | |||
| 114 | + | d.log.Error("palette command failed", "err", err, "session", sess.name, "command", cmd.Code) | |||
| 115 | + | } | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | if exit || changed { | |||
| 119 | + | d.paint(sess, ac, true) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func routePaletteEscape(m *palette.Model, data []byte) (int, bool) { | |||
| 124 | + | if len(data) >= 3 && data[1] == '[' { | |||
| 125 | + | switch data[2] { | |||
| 126 | + | case 'A': | |||
| 127 | + | m.Up() | |||
| 128 | + | return 3, true | |||
| 129 | + | case 'B': | |||
| 130 | + | m.Down() | |||
| 131 | + | return 3, true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return 0, false | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func isPaletteEscapePrefix(data []byte) bool { | |||
| 138 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | type paletteExec struct { | |||
| 142 | + | d *Daemon | |||
| 143 | + | sess *session | |||
| 144 | + | ac *attachedClient | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (e paletteExec) CreateTab() error { | |||
| 148 | + | if err := e.d.createTab(e.sess, e.ac.size); err != nil { | |||
| 149 | + | return err | |||
| 150 | + | } | |||
| 151 | + | e.d.paint(e.sess, e.ac, true) | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (e paletteExec) CloseTab() error { | |||
| 156 | + | if tb := e.sess.activeTab(); tb != nil { | |||
| 157 | + | e.d.closeTab(e.sess, tb, true) | |||
| 158 | + | } | |||
| 159 | + | return nil | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (e paletteExec) NextTab() error { | |||
| 163 | + | if e.sess.switchRelative(1) { | |||
| 164 | + | e.d.paint(e.sess, e.ac, true) | |||
| 165 | + | } | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (e paletteExec) PrevTab() error { | |||
| 170 | + | if e.sess.switchRelative(-1) { | |||
| 171 | + | e.d.paint(e.sess, e.ac, true) | |||
| 172 | + | } | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (e paletteExec) Detach() error { | |||
| 177 | + | e.d.clientGone(e.sess, e.ac, true) | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (e paletteExec) EnterCopyMode() error { | |||
| 182 | + | e.d.enterCopyMode(e.sess, e.ac) | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (e paletteExec) RenameSession() error { | |||
| 187 | + | e.sess.promoteEphemeral() | |||
| 188 | + | e.d.paint(e.sess, e.ac, true) | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func (e paletteExec) OpenSessionPicker() error { | |||
| 193 | + | e.d.enterPicker(e.sess, e.ac) | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |||
| 198 | + | inner := paletteModal.Composite(base, renderer.DefaultStyle()) | |||
| 199 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |||
| 200 | + | for y := range min(inner.Height, modalFrame.Height) { | |||
| 201 | + | for x := range min(inner.Width, modalFrame.Width) { | |||
| 202 | + | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return base, []renderer.Damage{renderer.FullRedraw()} | |||
| 206 | + | } | |||
A internal/usecase/daemon/palette_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |
| 12 | + | p1, release1 := newBlockingPTY(t) | |
| 13 | + | p2, release2 := newBlockingPTY(t) | |
| 14 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 15 | + | defer release1() | |
| 16 | + | defer release2() | |
| 17 | + | ||
| 18 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 19 | + | require.True(t, ac.paletteActive()) | |
| 20 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 21 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | require.Contains(t, string(msg.Data), "Commands") | |
| 24 | + | ||
| 25 | + | d.handleInput(sess, ac, []byte("NXT\r")) | |
| 26 | + | require.False(t, ac.paletteActive()) | |
| 27 | + | require.Equal(t, 1, activeTabIndex(sess)) | |
| 28 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 29 | + | ||
| 30 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 31 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 32 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 33 | + | require.False(t, ac.paletteActive()) | |
| 34 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) { | |
| 38 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 39 | + | defer releases[0]() | |
| 40 | + | ||
| 41 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 42 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 43 | + | d.handleInput(sess, ac, []byte("zzzz\r")) | |
| 44 | + | require.True(t, ac.paletteActive()) | |
| 45 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 46 | + | ||
| 47 | + | d.handlePaletteInput(ac, []byte{0x1b, '['}) | |
| 48 | + | require.True(t, ac.paletteActive()) | |
| 49 | + | require.Equal(t, []byte{0x1b, '['}, ac.palettePending) | |
| 50 | + | d.handlePaletteInput(ac, []byte{'A'}) | |
| 51 | + | require.True(t, ac.paletteActive()) | |
| 52 | + | require.Empty(t, ac.palettePending) | |
| 53 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestPaletteUTF8PendingCompletesFilter(t *testing.T) { | |
| 57 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 58 | + | defer releases[0]() | |
| 59 | + | ||
| 60 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 61 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 62 | + | d.handlePaletteInput(ac, []byte{0xc3}) | |
| 63 | + | require.Equal(t, []byte{0xc3}, ac.palettePending) | |
| 64 | + | d.handlePaletteInput(ac, []byte{0xa9}) | |
| 65 | + | require.Empty(t, ac.palettePending) | |
| 66 | + | require.Equal(t, "é", ac.palette.Query()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestPaletteExecMethods(t *testing.T) { | |
| 70 | + | p1, release1 := newBlockingPTY(t) | |
| 71 | + | p2, release2 := newBlockingPTY(t) | |
| 72 | + | p3, release3 := newBlockingPTY(t) | |
| 73 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3) | |
| 74 | + | defer release1() | |
| 75 | + | defer release2() | |
| 76 | + | defer release3() | |
| 77 | + | exec := paletteExec{d: d, sess: sess, ac: ac} | |
| 78 | + | ||
| 79 | + | require.NoError(t, exec.NextTab()) | |
| 80 | + | require.Equal(t, 1, activeTabIndex(sess)) | |
| 81 | + | require.NoError(t, exec.PrevTab()) | |
| 82 | + | require.Equal(t, 0, activeTabIndex(sess)) | |
| 83 | + | sess.ephemeral = true | |
| 84 | + | require.NoError(t, exec.RenameSession()) | |
| 85 | + | require.False(t, sess.ephemeral) | |
| 86 | + | require.NoError(t, exec.OpenSessionPicker()) | |
| 87 | + | require.True(t, ac.pickerActive()) | |
| 88 | + | d.closePicker(ac) | |
| 89 | + | require.NoError(t, exec.EnterCopyMode()) | |
| 90 | + | require.True(t, ac.copyModeActive()) | |
| 91 | + | ac.copyMu.Lock() | |
| 92 | + | ac.copyMode = nil | |
| 93 | + | ac.copyMu.Unlock() | |
| 94 | + | require.NoError(t, exec.CloseTab()) | |
| 95 | + | require.Len(t, sess.tabs, 2) | |
| 96 | + | // Drain paints generated by methods above. | |
| 97 | + | for len(sends) > 0 { | |
| 98 | + | <-sends | |
| 99 | + | } | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 12 | + | p1, release1 := newBlockingPTY(t) | |||
| 13 | + | p2, release2 := newBlockingPTY(t) | |||
| 14 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | |||
| 15 | + | defer release1() | |||
| 16 | + | defer release2() | |||
| 17 | + | ||||
| 18 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 19 | + | require.True(t, ac.paletteActive()) | |||
| 20 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 21 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | require.Contains(t, string(msg.Data), "Commands") | |||
| 24 | + | ||||
| 25 | + | d.handleInput(sess, ac, []byte("NXT\r")) | |||
| 26 | + | require.False(t, ac.paletteActive()) | |||
| 27 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 28 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 29 | + | ||||
| 30 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 31 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 32 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 33 | + | require.False(t, ac.paletteActive()) | |||
| 34 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) { | |||
| 38 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 39 | + | defer releases[0]() | |||
| 40 | + | ||||
| 41 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 42 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 43 | + | d.handleInput(sess, ac, []byte("zzzz\r")) | |||
| 44 | + | require.True(t, ac.paletteActive()) | |||
| 45 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 46 | + | ||||
| 47 | + | d.handlePaletteInput(ac, []byte{0x1b, '['}) | |||
| 48 | + | require.True(t, ac.paletteActive()) | |||
| 49 | + | require.Equal(t, []byte{0x1b, '['}, ac.palettePending) | |||
| 50 | + | d.handlePaletteInput(ac, []byte{'A'}) | |||
| 51 | + | require.True(t, ac.paletteActive()) | |||
| 52 | + | require.Empty(t, ac.palettePending) | |||
| 53 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestPaletteUTF8PendingCompletesFilter(t *testing.T) { | |||
| 57 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 58 | + | defer releases[0]() | |||
| 59 | + | ||||
| 60 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 61 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 62 | + | d.handlePaletteInput(ac, []byte{0xc3}) | |||
| 63 | + | require.Equal(t, []byte{0xc3}, ac.palettePending) | |||
| 64 | + | d.handlePaletteInput(ac, []byte{0xa9}) | |||
| 65 | + | require.Empty(t, ac.palettePending) | |||
| 66 | + | require.Equal(t, "é", ac.palette.Query()) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestPaletteExecMethods(t *testing.T) { | |||
| 70 | + | p1, release1 := newBlockingPTY(t) | |||
| 71 | + | p2, release2 := newBlockingPTY(t) | |||
| 72 | + | p3, release3 := newBlockingPTY(t) | |||
| 73 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2, p3) | |||
| 74 | + | defer release1() | |||
| 75 | + | defer release2() | |||
| 76 | + | defer release3() | |||
| 77 | + | exec := paletteExec{d: d, sess: sess, ac: ac} | |||
| 78 | + | ||||
| 79 | + | require.NoError(t, exec.NextTab()) | |||
| 80 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 81 | + | require.NoError(t, exec.PrevTab()) | |||
| 82 | + | require.Equal(t, 0, activeTabIndex(sess)) | |||
| 83 | + | sess.ephemeral = true | |||
| 84 | + | require.NoError(t, exec.RenameSession()) | |||
| 85 | + | require.False(t, sess.ephemeral) | |||
| 86 | + | require.NoError(t, exec.OpenSessionPicker()) | |||
| 87 | + | require.True(t, ac.pickerActive()) | |||
| 88 | + | d.closePicker(ac) | |||
| 89 | + | require.NoError(t, exec.EnterCopyMode()) | |||
| 90 | + | require.True(t, ac.copyModeActive()) | |||
| 91 | + | ac.copyMu.Lock() | |||
| 92 | + | ac.copyMode = nil | |||
| 93 | + | ac.copyMu.Unlock() | |||
| 94 | + | require.NoError(t, exec.CloseTab()) | |||
| 95 | + | require.Len(t, sess.tabs, 2) | |||
| 96 | + | // Drain paints generated by methods above. | |||
| 97 | + | for len(sends) > 0 { | |||
| 98 | + | <-sends | |||
| 99 | + | } | |||
| 100 | + | } | |||
M internal/usecase/daemon/picker_test.go +13 -23
| @@ -20,26 +20,16 @@ import ( | |||
| 20 | 20 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 21 | 21 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 22 | 22 | ||
| 23 | - | func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) { | |
| 24 | - | writes := make(chan []byte, 1) | |
| 23 | + | func TestAltTForwardsToPTY(t *testing.T) { | |
| 24 | + | writes := make(chan []byte, 2) | |
| 25 | 25 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 26 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 26 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 27 | 27 | defer releasePTY() | |
| 28 | 28 | ||
| 29 | 29 | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 30 | 30 | ||
| 31 | - | require.True(t, ac.pickerActive()) | |
| 32 | - | select { | |
| 33 | - | case got := <-writes: | |
| 34 | - | t.Fatalf("picker binding forwarded to PTY: %q", got) | |
| 35 | - | default: | |
| 36 | - | } | |
| 37 | - | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 38 | - | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 39 | - | require.NoError(t, err) | |
| 40 | - | data := string(msg.Data) | |
| 41 | - | require.Contains(t, data, "┌") | |
| 42 | - | require.Contains(t, data, "Sessions") | |
| 31 | + | require.False(t, ac.pickerActive()) | |
| 32 | + | require.Equal(t, []byte("\x1bt"), <-writes) | |
| 43 | 33 | } | |
| 44 | 34 | ||
| 45 | 35 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |
| @@ -50,7 +40,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||
| 50 | 40 | } | |
| 51 | 41 | }() | |
| 52 | 42 | ||
| 53 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 43 | + | d.enterPicker(sess, ac) | |
| 54 | 44 | awaitFrame(t, sends, ports.MsgOutput) | |
| 55 | 45 | d.handleInput(sess, ac, []byte("j")) | |
| 56 | 46 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -58,7 +48,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||
| 58 | 48 | ||
| 59 | 49 | require.Equal(t, 1, activeTabIndex(sess)) | |
| 60 | 50 | awaitFrame(t, sends, ports.MsgOutput) | |
| 61 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 51 | + | d.enterPicker(sess, ac) | |
| 62 | 52 | awaitFrame(t, sends, ports.MsgOutput) | |
| 63 | 53 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 64 | 54 | d.clock = clk | |
| @@ -89,7 +79,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||
| 89 | 79 | } | |
| 90 | 80 | }() | |
| 91 | 81 | ||
| 92 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 82 | + | d.enterPicker(sess, ac) | |
| 93 | 83 | awaitFrame(t, sends, ports.MsgOutput) | |
| 94 | 84 | for _, input := range tc.input { | |
| 95 | 85 | d.handleInput(sess, ac, input) | |
| @@ -105,7 +95,7 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 105 | 95 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 106 | 96 | defer releases[0]() | |
| 107 | 97 | ||
| 108 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 98 | + | d.enterPicker(sess, ac) | |
| 109 | 99 | awaitFrame(t, sends, ports.MsgOutput) | |
| 110 | 100 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 111 | 101 | d.clock = clk | |
| @@ -139,7 +129,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 139 | 129 | d.sessions[sess1.id] = sess1 | |
| 140 | 130 | d.sessions[sess2.id] = sess2 | |
| 141 | 131 | ||
| 142 | - | d.handleInput(sess1, ac1, []byte("\x1bt")) | |
| 132 | + | d.enterPicker(sess1, ac1) | |
| 143 | 133 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 144 | 134 | d.handleInput(sess1, ac1, []byte("j")) | |
| 145 | 135 | awaitFrame(t, sends1, ports.MsgOutput) | |
| @@ -164,7 +154,7 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) { | |||
| 164 | 154 | } | |
| 165 | 155 | }() | |
| 166 | 156 | ||
| 167 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 157 | + | d.enterPicker(sess, ac) | |
| 168 | 158 | awaitFrame(t, sends, ports.MsgOutput) | |
| 169 | 159 | d.handleInput(sess, ac, []byte("j")) | |
| 170 | 160 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -204,7 +194,7 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||
| 204 | 194 | d.sessions[sess1.id] = sess1 | |
| 205 | 195 | d.sessions[sess2.id] = sess2 | |
| 206 | 196 | ||
| 207 | - | d.handleInput(sess1, ac1, []byte("\x1bt")) | |
| 197 | + | d.enterPicker(sess1, ac1) | |
| 208 | 198 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 209 | 199 | d.handleInput(sess1, ac1, []byte("j")) | |
| 210 | 200 | awaitFrame(t, sends1, ports.MsgOutput) | |
| @@ -271,7 +261,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) { | |||
| 271 | 261 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 272 | 262 | defer releasePTY() | |
| 273 | 263 | ||
| 274 | - | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 264 | + | d.enterPicker(sess, ac) | |
| 275 | 265 | awaitFrame(t, sends, ports.MsgOutput) | |
| 276 | 266 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 277 | 267 | ||
| @@ -20,26 +20,16 @@ import ( | |||||
| 20 | // stubClock returns timers whose channel never fires, so a scheduler under it | 20 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 21 | // blocks in its debounce loop until the session context is cancelled. Used by | 21 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 22 | 22 | ||||
| 23 | - | func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) { | 23 | + | func TestAltTForwardsToPTY(t *testing.T) { |
| 24 | - | writes := make(chan []byte, 1) | 24 | + | writes := make(chan []byte, 2) |
| 25 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 25 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
| 26 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 26 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) |
| 27 | defer releasePTY() | 27 | defer releasePTY() | ||
| 28 | 28 | ||||
| 29 | d.handleInput(sess, ac, []byte("\x1bt")) | 29 | d.handleInput(sess, ac, []byte("\x1bt")) | ||
| 30 | 30 | ||||
| 31 | - | require.True(t, ac.pickerActive()) | 31 | + | require.False(t, ac.pickerActive()) |
| 32 | - | select { | 32 | + | require.Equal(t, []byte("\x1bt"), <-writes) |
| 33 | - | case got := <-writes: | |||
| 34 | - | t.Fatalf("picker binding forwarded to PTY: %q", got) | |||
| 35 | - | default: | |||
| 36 | - | } | |||
| 37 | - | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 38 | - | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 39 | - | require.NoError(t, err) | |||
| 40 | - | data := string(msg.Data) | |||
| 41 | - | require.Contains(t, data, "┌") | |||
| 42 | - | require.Contains(t, data, "Sessions") | |||
| 43 | } | 33 | } | ||
| 44 | 34 | ||||
| 45 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | 35 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | ||
| @@ -50,7 +40,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||||
| 50 | } | 40 | } | ||
| 51 | }() | 41 | }() | ||
| 52 | 42 | ||||
| 53 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 43 | + | d.enterPicker(sess, ac) |
| 54 | awaitFrame(t, sends, ports.MsgOutput) | 44 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 55 | d.handleInput(sess, ac, []byte("j")) | 45 | d.handleInput(sess, ac, []byte("j")) | ||
| 56 | awaitFrame(t, sends, ports.MsgOutput) | 46 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -58,7 +48,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||||
| 58 | 48 | ||||
| 59 | require.Equal(t, 1, activeTabIndex(sess)) | 49 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 60 | awaitFrame(t, sends, ports.MsgOutput) | 50 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 61 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 51 | + | d.enterPicker(sess, ac) |
| 62 | awaitFrame(t, sends, ports.MsgOutput) | 52 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 63 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 53 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 64 | d.clock = clk | 54 | d.clock = clk | ||
| @@ -89,7 +79,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||||
| 89 | } | 79 | } | ||
| 90 | }() | 80 | }() | ||
| 91 | 81 | ||||
| 92 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 82 | + | d.enterPicker(sess, ac) |
| 93 | awaitFrame(t, sends, ports.MsgOutput) | 83 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 94 | for _, input := range tc.input { | 84 | for _, input := range tc.input { | ||
| 95 | d.handleInput(sess, ac, input) | 85 | d.handleInput(sess, ac, input) | ||
| @@ -105,7 +95,7 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||||
| 105 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 95 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 106 | defer releases[0]() | 96 | defer releases[0]() | ||
| 107 | 97 | ||||
| 108 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 98 | + | d.enterPicker(sess, ac) |
| 109 | awaitFrame(t, sends, ports.MsgOutput) | 99 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 110 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | 100 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | ||
| 111 | d.clock = clk | 101 | d.clock = clk | ||
| @@ -139,7 +129,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 139 | d.sessions[sess1.id] = sess1 | 129 | d.sessions[sess1.id] = sess1 | ||
| 140 | d.sessions[sess2.id] = sess2 | 130 | d.sessions[sess2.id] = sess2 | ||
| 141 | 131 | ||||
| 142 | - | d.handleInput(sess1, ac1, []byte("\x1bt")) | 132 | + | d.enterPicker(sess1, ac1) |
| 143 | awaitFrame(t, sends1, ports.MsgOutput) | 133 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 144 | d.handleInput(sess1, ac1, []byte("j")) | 134 | d.handleInput(sess1, ac1, []byte("j")) | ||
| 145 | awaitFrame(t, sends1, ports.MsgOutput) | 135 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| @@ -164,7 +154,7 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) { | |||||
| 164 | } | 154 | } | ||
| 165 | }() | 155 | }() | ||
| 166 | 156 | ||||
| 167 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 157 | + | d.enterPicker(sess, ac) |
| 168 | awaitFrame(t, sends, ports.MsgOutput) | 158 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 169 | d.handleInput(sess, ac, []byte("j")) | 159 | d.handleInput(sess, ac, []byte("j")) | ||
| 170 | awaitFrame(t, sends, ports.MsgOutput) | 160 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -204,7 +194,7 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||||
| 204 | d.sessions[sess1.id] = sess1 | 194 | d.sessions[sess1.id] = sess1 | ||
| 205 | d.sessions[sess2.id] = sess2 | 195 | d.sessions[sess2.id] = sess2 | ||
| 206 | 196 | ||||
| 207 | - | d.handleInput(sess1, ac1, []byte("\x1bt")) | 197 | + | d.enterPicker(sess1, ac1) |
| 208 | awaitFrame(t, sends1, ports.MsgOutput) | 198 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 209 | d.handleInput(sess1, ac1, []byte("j")) | 199 | d.handleInput(sess1, ac1, []byte("j")) | ||
| 210 | awaitFrame(t, sends1, ports.MsgOutput) | 200 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| @@ -271,7 +261,7 @@ func TestPickerResizeRecomposesModal(t *testing.T) { | |||||
| 271 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 261 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 272 | defer releasePTY() | 262 | defer releasePTY() | ||
| 273 | 263 | ||||
| 274 | - | d.handleInput(sess, ac, []byte("\x1bt")) | 264 | + | d.enterPicker(sess, ac) |
| 275 | awaitFrame(t, sends, ports.MsgOutput) | 265 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 276 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | 266 | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | ||
| 277 | 267 | ||||
M internal/usecase/daemon/render.go +12 -5
| @@ -256,15 +256,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 256 | 256 | ac.copyMu.Unlock() | |
| 257 | 257 | ac.pickerMu.Lock() | |
| 258 | 258 | pickerActive := ac.picker != nil | |
| 259 | - | pickerModel := ac.picker | |
| 259 | + | pickerModel := ac.picker.Clone() | |
| 260 | 260 | previewTab := ac.pickerPreview | |
| 261 | + | ac.pickerMu.Unlock() | |
| 261 | 262 | preview := snapshotPickerPreview(previewTab) | |
| 263 | + | ac.paletteMu.Lock() | |
| 264 | + | paletteModel := ac.palette | |
| 265 | + | paletteActive := paletteModel != nil | |
| 262 | 266 | ||
| 263 | 267 | tb.mu.Lock() | |
| 264 | - | if reset || copyActive || pickerActive { | |
| 268 | + | if reset || copyActive || pickerActive || paletteActive { | |
| 265 | 269 | ac.rend.Reset() | |
| 266 | 270 | } | |
| 267 | - | if reset || pickerActive { | |
| 271 | + | if reset || pickerActive || paletteActive { | |
| 268 | 272 | ac.lastCursor.valid = false | |
| 269 | 273 | } | |
| 270 | 274 | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | |
| @@ -277,8 +281,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 277 | 281 | } | |
| 278 | 282 | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | |
| 279 | 283 | } | |
| 280 | - | ac.pickerMu.Unlock() | |
| 281 | - | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive) | |
| 284 | + | if paletteActive { | |
| 285 | + | frame, damage = composePaletteClientFrame(paletteModel, frame) | |
| 286 | + | } | |
| 287 | + | ac.paletteMu.Unlock() | |
| 288 | + | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive) | |
| 282 | 289 | data, err := ac.rend.Draw(frame, damage) | |
| 283 | 290 | var cursorTail []byte | |
| 284 | 291 | if err == nil { | |
| @@ -256,15 +256,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 256 | ac.copyMu.Unlock() | 256 | ac.copyMu.Unlock() | ||
| 257 | ac.pickerMu.Lock() | 257 | ac.pickerMu.Lock() | ||
| 258 | pickerActive := ac.picker != nil | 258 | pickerActive := ac.picker != nil | ||
| 259 | - | pickerModel := ac.picker | 259 | + | pickerModel := ac.picker.Clone() |
| 260 | previewTab := ac.pickerPreview | 260 | previewTab := ac.pickerPreview | ||
| 261 | + | ac.pickerMu.Unlock() | |||
| 261 | preview := snapshotPickerPreview(previewTab) | 262 | preview := snapshotPickerPreview(previewTab) | ||
| 263 | + | ac.paletteMu.Lock() | |||
| 264 | + | paletteModel := ac.palette | |||
| 265 | + | paletteActive := paletteModel != nil | |||
| 262 | 266 | ||||
| 263 | tb.mu.Lock() | 267 | tb.mu.Lock() | ||
| 264 | - | if reset || copyActive || pickerActive { | 268 | + | if reset || copyActive || pickerActive || paletteActive { |
| 265 | ac.rend.Reset() | 269 | ac.rend.Reset() | ||
| 266 | } | 270 | } | ||
| 267 | - | if reset || pickerActive { | 271 | + | if reset || pickerActive || paletteActive { |
| 268 | ac.lastCursor.valid = false | 272 | ac.lastCursor.valid = false | ||
| 269 | } | 273 | } | ||
| 270 | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | 274 | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | ||
| @@ -277,8 +281,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 277 | } | 281 | } | ||
| 278 | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | 282 | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | ||
| 279 | } | 283 | } | ||
| 280 | - | ac.pickerMu.Unlock() | 284 | + | if paletteActive { |
| 281 | - | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive) | 285 | + | frame, damage = composePaletteClientFrame(paletteModel, frame) |
| 286 | + | } | |||
| 287 | + | ac.paletteMu.Unlock() | |||
| 288 | + | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive) | |||
| 282 | data, err := ac.rend.Draw(frame, damage) | 289 | data, err := ac.rend.Draw(frame, damage) | ||
| 283 | var cursorTail []byte | 290 | var cursorTail []byte | ||
| 284 | if err == nil { | 291 | if err == nil { | ||
M internal/usecase/daemon/render_test.go +4 -2
| @@ -60,7 +60,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) { | |||
| 60 | 60 | <-clk.called | |
| 61 | 61 | ||
| 62 | 62 | d.handleInput(sess, ac, []byte("\x1b2")) | |
| 63 | - | d.handleInput(sess, ac, []byte("\x1bx")) | |
| 63 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 64 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |
| 64 | 65 | ||
| 65 | 66 | select { | |
| 66 | 67 | case <-waitGroupDone(&d.sessWg): | |
| @@ -98,7 +99,8 @@ func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |||
| 98 | 99 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 99 | 100 | defer releases[0]() | |
| 100 | 101 | ||
| 101 | - | d.handleInput(sess, ac, []byte("\x1bx")) | |
| 102 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 103 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |
| 102 | 104 | ||
| 103 | 105 | require.Equal(t, 0, sessionCount(d)) | |
| 104 | 106 | f := awaitFrame(t, sends, ports.MsgDetached) | |
| @@ -60,7 +60,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) { | |||||
| 60 | <-clk.called | 60 | <-clk.called | ||
| 61 | 61 | ||||
| 62 | d.handleInput(sess, ac, []byte("\x1b2")) | 62 | d.handleInput(sess, ac, []byte("\x1b2")) | ||
| 63 | - | d.handleInput(sess, ac, []byte("\x1bx")) | 63 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 64 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |||
| 64 | 65 | ||||
| 65 | select { | 66 | select { | ||
| 66 | case <-waitGroupDone(&d.sessWg): | 67 | case <-waitGroupDone(&d.sessWg): | ||
| @@ -98,7 +99,8 @@ func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |||||
| 98 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 99 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 99 | defer releases[0]() | 100 | defer releases[0]() | ||
| 100 | 101 | ||||
| 101 | - | d.handleInput(sess, ac, []byte("\x1bx")) | 102 | + | d.handleInput(sess, ac, []byte("\x1b ")) |
| 103 | + | d.handleInput(sess, ac, []byte("CLT\r")) | |||
| 102 | 104 | ||||
| 103 | require.Equal(t, 0, sessionCount(d)) | 105 | require.Equal(t, 0, sessionCount(d)) | ||
| 104 | f := awaitFrame(t, sends, ports.MsgDetached) | 106 | f := awaitFrame(t, sends, ports.MsgDetached) | ||
M internal/usecase/daemon/status_test.go +4 -1
| @@ -70,7 +70,8 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||
| 70 | 70 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 71 | 71 | tr, sends, releaseConn := newConn(t, | |
| 72 | 72 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}), | |
| 73 | - | frameInput([]byte("\x1bc")), | |
| 73 | + | frameInput([]byte("\x1b ")), | |
| 74 | + | frameInput([]byte("CNT\r")), | |
| 74 | 75 | frameInput([]byte("\x1b1")), | |
| 75 | 76 | ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})}, | |
| 76 | 77 | ) | |
| @@ -79,10 +80,12 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||
| 79 | 80 | hg.Go(func() { d.handleConn(tr) }) | |
| 80 | 81 | awaitFrame(t, sends, ports.MsgWelcome) | |
| 81 | 82 | first := awaitFrame(t, sends, ports.MsgOutput) | |
| 83 | + | palette := awaitFrame(t, sends, ports.MsgOutput) | |
| 82 | 84 | created := awaitFrame(t, sends, ports.MsgOutput) | |
| 83 | 85 | switched := awaitFrame(t, sends, ports.MsgOutput) | |
| 84 | 86 | resized := awaitFrame(t, sends, ports.MsgOutput) | |
| 85 | 87 | ||
| 88 | + | _ = palette | |
| 86 | 89 | for _, f := range []ports.Frame{first, created, switched, resized} { | |
| 87 | 90 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 88 | 91 | require.NoError(t, err) | |
| @@ -70,7 +70,8 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||||
| 70 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | 70 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | ||
| 71 | tr, sends, releaseConn := newConn(t, | 71 | tr, sends, releaseConn := newConn(t, | ||
| 72 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}), | 72 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}), | ||
| 73 | - | frameInput([]byte("\x1bc")), | 73 | + | frameInput([]byte("\x1b ")), |
| 74 | + | frameInput([]byte("CNT\r")), | |||
| 74 | frameInput([]byte("\x1b1")), | 75 | frameInput([]byte("\x1b1")), | ||
| 75 | ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})}, | 76 | ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})}, | ||
| 76 | ) | 77 | ) | ||
| @@ -79,10 +80,12 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||||
| 79 | hg.Go(func() { d.handleConn(tr) }) | 80 | hg.Go(func() { d.handleConn(tr) }) | ||
| 80 | awaitFrame(t, sends, ports.MsgWelcome) | 81 | awaitFrame(t, sends, ports.MsgWelcome) | ||
| 81 | first := awaitFrame(t, sends, ports.MsgOutput) | 82 | first := awaitFrame(t, sends, ports.MsgOutput) | ||
| 83 | + | palette := awaitFrame(t, sends, ports.MsgOutput) | |||
| 82 | created := awaitFrame(t, sends, ports.MsgOutput) | 84 | created := awaitFrame(t, sends, ports.MsgOutput) | ||
| 83 | switched := awaitFrame(t, sends, ports.MsgOutput) | 85 | switched := awaitFrame(t, sends, ports.MsgOutput) | ||
| 84 | resized := awaitFrame(t, sends, ports.MsgOutput) | 86 | resized := awaitFrame(t, sends, ports.MsgOutput) | ||
| 85 | 87 | ||||
| 88 | + | _ = palette | |||
| 86 | for _, f := range []ports.Frame{first, created, switched, resized} { | 89 | for _, f := range []ports.Frame{first, created, switched, resized} { | ||
| 87 | out, err := ports.UnmarshalOutput(f.Payload) | 90 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 88 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
M internal/usecase/keys/router.go +3 -24
| @@ -18,14 +18,7 @@ const ( | |||
| 18 | 18 | type Action int | |
| 19 | 19 | ||
| 20 | 20 | const ( | |
| 21 | - | ActionCreateTab Action = iota | |
| 22 | - | ActionNextTab | |
| 23 | - | ActionPreviousTab | |
| 24 | - | ActionDetach | |
| 25 | - | ActionCloseTab | |
| 26 | - | ActionCopyMode | |
| 27 | - | ActionRenameSession | |
| 28 | - | ActionOpenPicker | |
| 21 | + | ActionOpenPalette Action = iota | |
| 29 | 22 | ActionSwitchTab1 | |
| 30 | 23 | ActionSwitchTab2 | |
| 31 | 24 | ActionSwitchTab3 | |
| @@ -170,22 +163,8 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |||
| 170 | 163 | ||
| 171 | 164 | func binding(b byte) (Action, bool) { | |
| 172 | 165 | switch b { | |
| 173 | - | case 'c': | |
| 174 | - | return ActionCreateTab, true | |
| 175 | - | case 'n': | |
| 176 | - | return ActionNextTab, true | |
| 177 | - | case 'p': | |
| 178 | - | return ActionPreviousTab, true | |
| 179 | - | case 'd': | |
| 180 | - | return ActionDetach, true | |
| 181 | - | case 'x': | |
| 182 | - | return ActionCloseTab, true | |
| 183 | - | case 'u': | |
| 184 | - | return ActionCopyMode, true | |
| 185 | - | case 'r': | |
| 186 | - | return ActionRenameSession, true | |
| 187 | - | case 't': | |
| 188 | - | return ActionOpenPicker, true | |
| 166 | + | case ' ': | |
| 167 | + | return ActionOpenPalette, true | |
| 189 | 168 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | |
| 190 | 169 | return ActionSwitchTab1 + Action(b-'1'), true | |
| 191 | 170 | default: | |
| @@ -18,14 +18,7 @@ const ( | |||||
| 18 | type Action int | 18 | type Action int | ||
| 19 | 19 | ||||
| 20 | const ( | 20 | const ( | ||
| 21 | - | ActionCreateTab Action = iota | 21 | + | ActionOpenPalette Action = iota |
| 22 | - | ActionNextTab | |||
| 23 | - | ActionPreviousTab | |||
| 24 | - | ActionDetach | |||
| 25 | - | ActionCloseTab | |||
| 26 | - | ActionCopyMode | |||
| 27 | - | ActionRenameSession | |||
| 28 | - | ActionOpenPicker | |||
| 29 | ActionSwitchTab1 | 22 | ActionSwitchTab1 | ||
| 30 | ActionSwitchTab2 | 23 | ActionSwitchTab2 | ||
| 31 | ActionSwitchTab3 | 24 | ActionSwitchTab3 | ||
| @@ -170,22 +163,8 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |||||
| 170 | 163 | ||||
| 171 | func binding(b byte) (Action, bool) { | 164 | func binding(b byte) (Action, bool) { | ||
| 172 | switch b { | 165 | switch b { | ||
| 173 | - | case 'c': | 166 | + | case ' ': |
| 174 | - | return ActionCreateTab, true | 167 | + | return ActionOpenPalette, true |
| 175 | - | case 'n': | |||
| 176 | - | return ActionNextTab, true | |||
| 177 | - | case 'p': | |||
| 178 | - | return ActionPreviousTab, true | |||
| 179 | - | case 'd': | |||
| 180 | - | return ActionDetach, true | |||
| 181 | - | case 'x': | |||
| 182 | - | return ActionCloseTab, true | |||
| 183 | - | case 'u': | |||
| 184 | - | return ActionCopyMode, true | |||
| 185 | - | case 'r': | |||
| 186 | - | return ActionRenameSession, true | |||
| 187 | - | case 't': | |||
| 188 | - | return ActionOpenPicker, true | |||
| 189 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | 168 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | ||
| 190 | return ActionSwitchTab1 + Action(b-'1'), true | 169 | return ActionSwitchTab1 + Action(b-'1'), true | ||
| 191 | default: | 170 | default: | ||
M internal/usecase/keys/router_test.go +27 -48
| @@ -45,31 +45,13 @@ func (h *captureHandler) Forward(data []byte) { | |||
| 45 | 45 | } | |
| 46 | 46 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | |
| 47 | 47 | ||
| 48 | - | func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | |
| 49 | - | cases := []struct { | |
| 50 | - | name string | |
| 51 | - | in []byte | |
| 52 | - | want Action | |
| 53 | - | }{ | |
| 54 | - | {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab}, | |
| 55 | - | {"Alt+n", []byte{ESC, 'n'}, ActionNextTab}, | |
| 56 | - | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab}, | |
| 57 | - | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | |
| 58 | - | {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab}, | |
| 59 | - | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | |
| 60 | - | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | |
| 61 | - | {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker}, | |
| 62 | - | } | |
| 63 | - | for _, tc := range cases { | |
| 64 | - | t.Run(tc.name, func(t *testing.T) { | |
| 65 | - | clk := &fakeClock{} | |
| 66 | - | h := &captureHandler{} | |
| 67 | - | NewRouter(clk, h).Route(tc.in) | |
| 68 | - | require.Equal(t, []Action{tc.want}, h.actions) | |
| 69 | - | require.Empty(t, h.forwards) | |
| 70 | - | require.Empty(t, clk.timers) | |
| 71 | - | }) | |
| 72 | - | } | |
| 48 | + | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { | |
| 49 | + | clk := &fakeClock{} | |
| 50 | + | h := &captureHandler{} | |
| 51 | + | NewRouter(clk, h).Route([]byte{ESC, ' '}) | |
| 52 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 53 | + | require.Empty(t, h.forwards) | |
| 54 | + | require.Empty(t, clk.timers) | |
| 73 | 55 | } | |
| 74 | 56 | ||
| 75 | 57 | func TestRouterInterceptsAltDigits(t *testing.T) { | |
| @@ -87,11 +69,23 @@ func TestRouterInterceptsAltDigits(t *testing.T) { | |||
| 87 | 69 | require.Empty(t, h.forwards) | |
| 88 | 70 | } | |
| 89 | 71 | ||
| 72 | + | func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |
| 73 | + | for _, b := range []byte("cnpdxurt") { | |
| 74 | + | t.Run(string(b), func(t *testing.T) { | |
| 75 | + | clk := &fakeClock{} | |
| 76 | + | h := &captureHandler{} | |
| 77 | + | NewRouter(clk, h).Route([]byte{ESC, b}) | |
| 78 | + | require.Empty(t, h.actions) | |
| 79 | + | require.Equal(t, [][]byte{{ESC, b}}, h.forwards) | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 90 | 84 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |
| 91 | 85 | clk := &fakeClock{} | |
| 92 | 86 | h := &captureHandler{} | |
| 93 | - | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | |
| 94 | - | require.Equal(t, []Action{ActionCreateTab}, h.actions) | |
| 87 | + | NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'}) | |
| 88 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 95 | 89 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | |
| 96 | 90 | } | |
| 97 | 91 | ||
| @@ -116,24 +110,9 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||
| 116 | 110 | require.Equal(t, ESCDelay, clk.timers[0].d) | |
| 117 | 111 | require.Empty(t, h.forwards) | |
| 118 | 112 | ||
| 119 | - | r.Route([]byte{'n'}) | |
| 120 | - | require.True(t, clk.timers[0].stopped) | |
| 121 | - | require.Equal(t, []Action{ActionNextTab}, h.actions) | |
| 122 | - | require.Empty(t, h.forwards) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) { | |
| 126 | - | clk := &fakeClock{} | |
| 127 | - | h := &captureHandler{} | |
| 128 | - | r := NewRouter(clk, h) | |
| 129 | - | r.Route([]byte{ESC}) | |
| 130 | - | require.Len(t, clk.timers, 1) | |
| 131 | - | require.Equal(t, ESCDelay, clk.timers[0].d) | |
| 132 | - | require.Empty(t, h.forwards) | |
| 133 | - | ||
| 134 | - | r.Route([]byte{'t'}) | |
| 113 | + | r.Route([]byte{' '}) | |
| 135 | 114 | require.True(t, clk.timers[0].stopped) | |
| 136 | - | require.Equal(t, []Action{ActionOpenPicker}, h.actions) | |
| 115 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 137 | 116 | require.Empty(t, h.forwards) | |
| 138 | 117 | } | |
| 139 | 118 | ||
| @@ -179,11 +158,11 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||
| 179 | 158 | r := NewRouter(clk, h) | |
| 180 | 159 | r.Route([]byte{ESC}) | |
| 181 | 160 | ||
| 182 | - | r.Route([]byte{'[', 'A', ESC, 'c'}) | |
| 161 | + | r.Route([]byte{'[', 'A', ESC, ' '}) | |
| 183 | 162 | ||
| 184 | 163 | require.True(t, clk.timers[0].stopped) | |
| 185 | 164 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | |
| 186 | - | require.Equal(t, []Action{ActionCreateTab}, h.actions) | |
| 165 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 187 | 166 | } | |
| 188 | 167 | ||
| 189 | 168 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |
| @@ -195,10 +174,10 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |||
| 195 | 174 | r.Route([]byte{ESC}) | |
| 196 | 175 | require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond) | |
| 197 | 176 | ||
| 198 | - | r.Route([]byte{'n'}) | |
| 177 | + | r.Route([]byte{' '}) | |
| 199 | 178 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | |
| 200 | 179 | require.True(t, clk.timers[0].stopped) | |
| 201 | - | require.Equal(t, []Action{ActionNextTab}, h.actions) | |
| 180 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) | |
| 202 | 181 | require.Empty(t, h.forwards) | |
| 203 | 182 | } | |
| 204 | 183 | ||
| @@ -45,31 +45,13 @@ func (h *captureHandler) Forward(data []byte) { | |||||
| 45 | } | 45 | } | ||
| 46 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | 46 | func (h *captureHandler) Action(a Action) { h.actions = append(h.actions, a) } | ||
| 47 | 47 | ||||
| 48 | - | func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | 48 | + | func TestRouterInterceptsAltSpaceForPalette(t *testing.T) { |
| 49 | - | cases := []struct { | 49 | + | clk := &fakeClock{} |
| 50 | - | name string | 50 | + | h := &captureHandler{} |
| 51 | - | in []byte | 51 | + | NewRouter(clk, h).Route([]byte{ESC, ' '}) |
| 52 | - | want Action | 52 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) |
| 53 | - | }{ | 53 | + | require.Empty(t, h.forwards) |
| 54 | - | {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab}, | 54 | + | require.Empty(t, clk.timers) |
| 55 | - | {"Alt+n", []byte{ESC, 'n'}, ActionNextTab}, | |||
| 56 | - | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab}, | |||
| 57 | - | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | |||
| 58 | - | {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab}, | |||
| 59 | - | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | |||
| 60 | - | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | |||
| 61 | - | {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker}, | |||
| 62 | - | } | |||
| 63 | - | for _, tc := range cases { | |||
| 64 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 65 | - | clk := &fakeClock{} | |||
| 66 | - | h := &captureHandler{} | |||
| 67 | - | NewRouter(clk, h).Route(tc.in) | |||
| 68 | - | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 69 | - | require.Empty(t, h.forwards) | |||
| 70 | - | require.Empty(t, clk.timers) | |||
| 71 | - | }) | |||
| 72 | - | } | |||
| 73 | } | 55 | } | ||
| 74 | 56 | ||||
| 75 | func TestRouterInterceptsAltDigits(t *testing.T) { | 57 | func TestRouterInterceptsAltDigits(t *testing.T) { | ||
| @@ -87,11 +69,23 @@ func TestRouterInterceptsAltDigits(t *testing.T) { | |||||
| 87 | require.Empty(t, h.forwards) | 69 | require.Empty(t, h.forwards) | ||
| 88 | } | 70 | } | ||
| 89 | 71 | ||||
| 72 | + | func TestRouterForwardsRemovedAltLetterBindings(t *testing.T) { | |||
| 73 | + | for _, b := range []byte("cnpdxurt") { | |||
| 74 | + | t.Run(string(b), func(t *testing.T) { | |||
| 75 | + | clk := &fakeClock{} | |||
| 76 | + | h := &captureHandler{} | |||
| 77 | + | NewRouter(clk, h).Route([]byte{ESC, b}) | |||
| 78 | + | require.Empty(t, h.actions) | |||
| 79 | + | require.Equal(t, [][]byte{{ESC, b}}, h.forwards) | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 90 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | 84 | func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | ||
| 91 | clk := &fakeClock{} | 85 | clk := &fakeClock{} | ||
| 92 | h := &captureHandler{} | 86 | h := &captureHandler{} | ||
| 93 | - | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | 87 | + | NewRouter(clk, h).Route([]byte{'a', ESC, ' ', 'b'}) |
| 94 | - | require.Equal(t, []Action{ActionCreateTab}, h.actions) | 88 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) |
| 95 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | 89 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | ||
| 96 | } | 90 | } | ||
| 97 | 91 | ||||
| @@ -116,24 +110,9 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||||
| 116 | require.Equal(t, ESCDelay, clk.timers[0].d) | 110 | require.Equal(t, ESCDelay, clk.timers[0].d) | ||
| 117 | require.Empty(t, h.forwards) | 111 | require.Empty(t, h.forwards) | ||
| 118 | 112 | ||||
| 119 | - | r.Route([]byte{'n'}) | 113 | + | r.Route([]byte{' '}) |
| 120 | - | require.True(t, clk.timers[0].stopped) | |||
| 121 | - | require.Equal(t, []Action{ActionNextTab}, h.actions) | |||
| 122 | - | require.Empty(t, h.forwards) | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) { | |||
| 126 | - | clk := &fakeClock{} | |||
| 127 | - | h := &captureHandler{} | |||
| 128 | - | r := NewRouter(clk, h) | |||
| 129 | - | r.Route([]byte{ESC}) | |||
| 130 | - | require.Len(t, clk.timers, 1) | |||
| 131 | - | require.Equal(t, ESCDelay, clk.timers[0].d) | |||
| 132 | - | require.Empty(t, h.forwards) | |||
| 133 | - | ||||
| 134 | - | r.Route([]byte{'t'}) | |||
| 135 | require.True(t, clk.timers[0].stopped) | 114 | require.True(t, clk.timers[0].stopped) | ||
| 136 | - | require.Equal(t, []Action{ActionOpenPicker}, h.actions) | 115 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) |
| 137 | require.Empty(t, h.forwards) | 116 | require.Empty(t, h.forwards) | ||
| 138 | } | 117 | } | ||
| 139 | 118 | ||||
| @@ -179,11 +158,11 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||||
| 179 | r := NewRouter(clk, h) | 158 | r := NewRouter(clk, h) | ||
| 180 | r.Route([]byte{ESC}) | 159 | r.Route([]byte{ESC}) | ||
| 181 | 160 | ||||
| 182 | - | r.Route([]byte{'[', 'A', ESC, 'c'}) | 161 | + | r.Route([]byte{'[', 'A', ESC, ' '}) |
| 183 | 162 | ||||
| 184 | require.True(t, clk.timers[0].stopped) | 163 | require.True(t, clk.timers[0].stopped) | ||
| 185 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | 164 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | ||
| 186 | - | require.Equal(t, []Action{ActionCreateTab}, h.actions) | 165 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) |
| 187 | } | 166 | } | ||
| 188 | 167 | ||||
| 189 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | 168 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | ||
| @@ -195,10 +174,10 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |||||
| 195 | r.Route([]byte{ESC}) | 174 | r.Route([]byte{ESC}) | ||
| 196 | require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond) | 175 | require.Eventually(t, func() bool { return retainESCWaiters() > before }, time.Second, time.Millisecond) | ||
| 197 | 176 | ||||
| 198 | - | r.Route([]byte{'n'}) | 177 | + | r.Route([]byte{' '}) |
| 199 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | 178 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | ||
| 200 | require.True(t, clk.timers[0].stopped) | 179 | require.True(t, clk.timers[0].stopped) | ||
| 201 | - | require.Equal(t, []Action{ActionNextTab}, h.actions) | 180 | + | require.Equal(t, []Action{ActionOpenPalette}, h.actions) |
| 202 | require.Empty(t, h.forwards) | 181 | require.Empty(t, h.forwards) | ||
| 203 | } | 182 | } | ||
| 204 | 183 | ||||
M internal/usecase/palette/model.go +3 -0
| @@ -114,6 +114,9 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||
| 114 | 114 | if visible <= 0 { | |
| 115 | 115 | return frame | |
| 116 | 116 | } | |
| 117 | + | if len(m.matches) == 0 { | |
| 118 | + | return frame | |
| 119 | + | } | |
| 117 | 120 | if m.selected < m.scroll { | |
| 118 | 121 | m.scroll = m.selected | |
| 119 | 122 | } | |
| @@ -114,6 +114,9 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||||
| 114 | if visible <= 0 { | 114 | if visible <= 0 { | ||
| 115 | return frame | 115 | return frame | ||
| 116 | } | 116 | } | ||
| 117 | + | if len(m.matches) == 0 { | |||
| 118 | + | return frame | |||
| 119 | + | } | |||
| 117 | if m.selected < m.scroll { | 120 | if m.selected < m.scroll { | ||
| 118 | m.scroll = m.selected | 121 | m.scroll = m.selected | ||
| 119 | } | 122 | } | ||
M internal/usecase/picker/model.go +9 -0
| @@ -125,6 +125,15 @@ func (m *Model) Selected() (Target, bool) { | |||
| 125 | 125 | return Target{Session: r.session, TabIndex: r.tabIndex}, true | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func (m *Model) Clone() *Model { | |
| 129 | + | if m == nil { | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | clone := *m | |
| 133 | + | clone.rows = append([]row(nil), m.rows...) | |
| 134 | + | return &clone | |
| 135 | + | } | |
| 136 | + | ||
| 128 | 137 | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | |
| 129 | 138 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 130 | 139 | layout := ChooseLayout(inner) | |
| @@ -125,6 +125,15 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 125 | return Target{Session: r.session, TabIndex: r.tabIndex}, true | 125 | return Target{Session: r.session, TabIndex: r.tabIndex}, true | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func (m *Model) Clone() *Model { | |||
| 129 | + | if m == nil { | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | clone := *m | |||
| 133 | + | clone.rows = append([]row(nil), m.rows...) | |||
| 134 | + | return &clone | |||
| 135 | + | } | |||
| 136 | + | ||||
| 128 | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | 137 | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | ||
| 129 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 138 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 130 | layout := ChooseLayout(inner) | 139 | layout := ChooseLayout(inner) | ||