feat(panes): add split and stacked panes
- commit
- 181ce78d68ef6fd53acb6b157d2335f52771e0c3
- author
- brice · · 2026-07-04 03:45 UTC
- parent
- 56bc502
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
39 files · +2775 -215
M internal/adapters/pty/pty_linux.go +20 -0
| @@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int { | |||
| 161 | 161 | return p.cmd.Process.Pid | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | + | // ForegroundPgid reports the process group currently in the foreground for the | |
| 165 | + | // pty's controlling terminal. | |
| 166 | + | func (p *linuxPTY) ForegroundPgid() (int, error) { | |
| 167 | + | rc, err := p.master.SyscallConn() | |
| 168 | + | if err != nil { | |
| 169 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |
| 170 | + | } | |
| 171 | + | var pgid int | |
| 172 | + | var ioctlErr error | |
| 173 | + | if err := rc.Control(func(fd uintptr) { | |
| 174 | + | pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP) | |
| 175 | + | }); err != nil { | |
| 176 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |
| 177 | + | } | |
| 178 | + | if ioctlErr != nil { | |
| 179 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr) | |
| 180 | + | } | |
| 181 | + | return pgid, nil | |
| 182 | + | } | |
| 183 | + | ||
| 164 | 184 | // Close terminates the child and releases the master. It signals the child's | |
| 165 | 185 | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | |
| 166 | 186 | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | |
| @@ -161,6 +161,26 @@ func (p *linuxPTY) Pid() int { | |||||
| 161 | return p.cmd.Process.Pid | 161 | return p.cmd.Process.Pid | ||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | + | // ForegroundPgid reports the process group currently in the foreground for the | |||
| 165 | + | // pty's controlling terminal. | |||
| 166 | + | func (p *linuxPTY) ForegroundPgid() (int, error) { | |||
| 167 | + | rc, err := p.master.SyscallConn() | |||
| 168 | + | if err != nil { | |||
| 169 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |||
| 170 | + | } | |||
| 171 | + | var pgid int | |||
| 172 | + | var ioctlErr error | |||
| 173 | + | if err := rc.Control(func(fd uintptr) { | |||
| 174 | + | pgid, ioctlErr = unix.IoctlGetInt(int(fd), unix.TIOCGPGRP) | |||
| 175 | + | }); err != nil { | |||
| 176 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", err) | |||
| 177 | + | } | |||
| 178 | + | if ioctlErr != nil { | |||
| 179 | + | return 0, fmt.Errorf("pty: foreground pgid: %w", ioctlErr) | |||
| 180 | + | } | |||
| 181 | + | return pgid, nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 164 | // Close terminates the child and releases the master. It signals the child's | 184 | // Close terminates the child and releases the master. It signals the child's | ||
| 165 | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | 185 | // process group with SIGHUP, waits up to killGracePeriod for it to exit, then | ||
| 166 | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | 186 | // escalates to SIGKILL. The child is reaped (via cmd.Wait) to avoid a zombie, | ||
M internal/adapters/pty/pty_linux_test.go +14 -0
| @@ -98,6 +98,20 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||
| 98 | 98 | require.NoError(t, p.Close()) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestForegroundPgid(t *testing.T) { | |
| 102 | + | if testing.Short() { | |
| 103 | + | t.Skip("skipping pty integration test in -short mode") | |
| 104 | + | } | |
| 105 | + | f := newFactory() | |
| 106 | + | p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | t.Cleanup(func() { _ = p.Close() }) | |
| 109 | + | ||
| 110 | + | pgid, err := p.ForegroundPgid() | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | require.Greater(t, pgid, 0) | |
| 113 | + | } | |
| 114 | + | ||
| 101 | 115 | func TestResize_SttySize(t *testing.T) { | |
| 102 | 116 | if testing.Short() { | |
| 103 | 117 | t.Skip("skipping pty integration test in -short mode") | |
| @@ -98,6 +98,20 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||||
| 98 | require.NoError(t, p.Close()) | 98 | require.NoError(t, p.Close()) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestForegroundPgid(t *testing.T) { | |||
| 102 | + | if testing.Short() { | |||
| 103 | + | t.Skip("skipping pty integration test in -short mode") | |||
| 104 | + | } | |||
| 105 | + | f := newFactory() | |||
| 106 | + | p, err := f.Open("sh", []string{"-c", "sleep 2"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | t.Cleanup(func() { _ = p.Close() }) | |||
| 109 | + | ||||
| 110 | + | pgid, err := p.ForegroundPgid() | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | require.Greater(t, pgid, 0) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 101 | func TestResize_SttySize(t *testing.T) { | 115 | func TestResize_SttySize(t *testing.T) { | ||
| 102 | if testing.Short() { | 116 | if testing.Short() { | ||
| 103 | t.Skip("skipping pty integration test in -short mode") | 117 | t.Skip("skipping pty integration test in -short mode") | ||
M internal/platform/proc_linux.go +17 -0
| @@ -3,9 +3,26 @@ package platform | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | + | "strings" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // ProcessCwd returns the current working directory for a Linux process. | |
| 9 | 10 | func ProcessCwd(pid int) (string, error) { | |
| 10 | 11 | return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) | |
| 11 | 12 | } | |
| 13 | + | ||
| 14 | + | // ProcessComm returns the command name from /proc/<pid>/comm. | |
| 15 | + | func ProcessComm(pid int) (string, error) { | |
| 16 | + | if pid <= 0 { | |
| 17 | + | return "", fmt.Errorf("process comm: invalid pid %d", pid) | |
| 18 | + | } | |
| 19 | + | data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) | |
| 20 | + | if err != nil { | |
| 21 | + | return "", fmt.Errorf("process comm: read pid %d: %w", pid, err) | |
| 22 | + | } | |
| 23 | + | comm := strings.TrimSpace(string(data)) | |
| 24 | + | if comm == "" { | |
| 25 | + | return "", fmt.Errorf("process comm: empty comm for pid %d", pid) | |
| 26 | + | } | |
| 27 | + | return comm, nil | |
| 28 | + | } | |
| @@ -3,9 +3,26 @@ package platform | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | + | "strings" | |||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | // ProcessCwd returns the current working directory for a Linux process. | 9 | // ProcessCwd returns the current working directory for a Linux process. | ||
| 9 | func ProcessCwd(pid int) (string, error) { | 10 | func ProcessCwd(pid int) (string, error) { | ||
| 10 | return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) | 11 | return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) | ||
| 11 | } | 12 | } | ||
| 13 | + | ||||
| 14 | + | // ProcessComm returns the command name from /proc/<pid>/comm. | |||
| 15 | + | func ProcessComm(pid int) (string, error) { | |||
| 16 | + | if pid <= 0 { | |||
| 17 | + | return "", fmt.Errorf("process comm: invalid pid %d", pid) | |||
| 18 | + | } | |||
| 19 | + | data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) | |||
| 20 | + | if err != nil { | |||
| 21 | + | return "", fmt.Errorf("process comm: read pid %d: %w", pid, err) | |||
| 22 | + | } | |||
| 23 | + | comm := strings.TrimSpace(string(data)) | |||
| 24 | + | if comm == "" { | |||
| 25 | + | return "", fmt.Errorf("process comm: empty comm for pid %d", pid) | |||
| 26 | + | } | |||
| 27 | + | return comm, nil | |||
| 28 | + | } | |||
A internal/platform/proc_linux_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package platform | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestProcessComm_CurrentProcess(t *testing.T) { | |
| 13 | + | comm, err := ProcessComm(os.Getpid()) | |
| 14 | + | require.NoError(t, err) | |
| 15 | + | require.NotEmpty(t, comm) | |
| 16 | + | require.NotContains(t, comm, "\n") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestProcessComm_InvalidPID(t *testing.T) { | |
| 20 | + | comm, err := ProcessComm(0) | |
| 21 | + | require.Error(t, err) | |
| 22 | + | require.Empty(t, comm) | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package platform | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestProcessComm_CurrentProcess(t *testing.T) { | |||
| 13 | + | comm, err := ProcessComm(os.Getpid()) | |||
| 14 | + | require.NoError(t, err) | |||
| 15 | + | require.NotEmpty(t, comm) | |||
| 16 | + | require.NotContains(t, comm, "\n") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestProcessComm_InvalidPID(t *testing.T) { | |||
| 20 | + | comm, err := ProcessComm(0) | |||
| 21 | + | require.Error(t, err) | |||
| 22 | + | require.Empty(t, comm) | |||
| 23 | + | } | |||
M internal/ports/mocks/mocks.go +53 -0
| @@ -84,6 +84,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call | |||
| 84 | 84 | return _c | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | // ForegroundPgid provides a mock function for the type MockPTY | |
| 88 | + | func (_mock *MockPTY) ForegroundPgid() (int, error) { | |
| 89 | + | ret := _mock.Called() | |
| 90 | + | ||
| 91 | + | if len(ret) == 0 { | |
| 92 | + | panic("no return value specified for ForegroundPgid") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | var r0 int | |
| 96 | + | var r1 error | |
| 97 | + | if returnFunc, ok := ret.Get(0).(func() (int, error)); ok { | |
| 98 | + | return returnFunc() | |
| 99 | + | } | |
| 100 | + | if returnFunc, ok := ret.Get(0).(func() int); ok { | |
| 101 | + | r0 = returnFunc() | |
| 102 | + | } else { | |
| 103 | + | r0 = ret.Get(0).(int) | |
| 104 | + | } | |
| 105 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 106 | + | r1 = returnFunc() | |
| 107 | + | } else { | |
| 108 | + | r1 = ret.Error(1) | |
| 109 | + | } | |
| 110 | + | return r0, r1 | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid' | |
| 114 | + | type MockPTY_ForegroundPgid_Call struct { | |
| 115 | + | *mock.Call | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // ForegroundPgid is a helper method to define mock.On call | |
| 119 | + | func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call { | |
| 120 | + | return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call { | |
| 124 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 125 | + | run() | |
| 126 | + | }) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call { | |
| 131 | + | _c.Call.Return(n, err) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call { | |
| 136 | + | _c.Call.Return(run) | |
| 137 | + | return _c | |
| 138 | + | } | |
| 139 | + | ||
| 87 | 140 | // Pid provides a mock function for the type MockPTY | |
| 88 | 141 | func (_mock *MockPTY) Pid() int { | |
| 89 | 142 | ret := _mock.Called() | |
| @@ -84,6 +84,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call | |||||
| 84 | return _c | 84 | return _c | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | + | // ForegroundPgid provides a mock function for the type MockPTY | |||
| 88 | + | func (_mock *MockPTY) ForegroundPgid() (int, error) { | |||
| 89 | + | ret := _mock.Called() | |||
| 90 | + | ||||
| 91 | + | if len(ret) == 0 { | |||
| 92 | + | panic("no return value specified for ForegroundPgid") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | var r0 int | |||
| 96 | + | var r1 error | |||
| 97 | + | if returnFunc, ok := ret.Get(0).(func() (int, error)); ok { | |||
| 98 | + | return returnFunc() | |||
| 99 | + | } | |||
| 100 | + | if returnFunc, ok := ret.Get(0).(func() int); ok { | |||
| 101 | + | r0 = returnFunc() | |||
| 102 | + | } else { | |||
| 103 | + | r0 = ret.Get(0).(int) | |||
| 104 | + | } | |||
| 105 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 106 | + | r1 = returnFunc() | |||
| 107 | + | } else { | |||
| 108 | + | r1 = ret.Error(1) | |||
| 109 | + | } | |||
| 110 | + | return r0, r1 | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid' | |||
| 114 | + | type MockPTY_ForegroundPgid_Call struct { | |||
| 115 | + | *mock.Call | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // ForegroundPgid is a helper method to define mock.On call | |||
| 119 | + | func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call { | |||
| 120 | + | return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")} | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call { | |||
| 124 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 125 | + | run() | |||
| 126 | + | }) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call { | |||
| 131 | + | _c.Call.Return(n, err) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call { | |||
| 136 | + | _c.Call.Return(run) | |||
| 137 | + | return _c | |||
| 138 | + | } | |||
| 139 | + | ||||
| 87 | // Pid provides a mock function for the type MockPTY | 140 | // Pid provides a mock function for the type MockPTY | ||
| 88 | func (_mock *MockPTY) Pid() int { | 141 | func (_mock *MockPTY) Pid() int { | ||
| 89 | ret := _mock.Called() | 142 | ret := _mock.Called() | ||
M internal/ports/ports.go +1 -0
| @@ -12,6 +12,7 @@ type PTY interface { | |||
| 12 | 12 | io.ReadWriteCloser // Read = child output, Write = child input | |
| 13 | 13 | Resize(sz domain.Size) error | |
| 14 | 14 | Pid() int | |
| 15 | + | ForegroundPgid() (int, error) | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 17 | 18 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| @@ -12,6 +12,7 @@ type PTY interface { | |||||
| 12 | io.ReadWriteCloser // Read = child output, Write = child input | 12 | io.ReadWriteCloser // Read = child output, Write = child input | ||
| 13 | Resize(sz domain.Size) error | 13 | Resize(sz domain.Size) error | ||
| 14 | Pid() int | 14 | Pid() int | ||
| 15 | + | ForegroundPgid() (int, error) | |||
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 17 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | 18 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | ||
M internal/usecase/command/command.go +11 -0
| @@ -5,6 +5,17 @@ type Context interface { | |||
| 5 | 5 | CreateTab() error | |
| 6 | 6 | CreateSession() error | |
| 7 | 7 | CloseTab() error | |
| 8 | + | SplitRight() error | |
| 9 | + | SplitLeft() error | |
| 10 | + | SplitUp() error | |
| 11 | + | SplitDown() error | |
| 12 | + | StackPane() error | |
| 13 | + | ToggleStack() error | |
| 14 | + | ClosePane() error | |
| 15 | + | FocusPaneLeft() error | |
| 16 | + | FocusPaneRight() error | |
| 17 | + | FocusPaneUp() error | |
| 18 | + | FocusPaneDown() error | |
| 8 | 19 | NextTab() error | |
| 9 | 20 | PrevTab() error | |
| 10 | 21 | Detach() error | |
| @@ -5,6 +5,17 @@ type Context interface { | |||||
| 5 | CreateTab() error | 5 | CreateTab() error | ||
| 6 | CreateSession() error | 6 | CreateSession() error | ||
| 7 | CloseTab() error | 7 | CloseTab() error | ||
| 8 | + | SplitRight() error | |||
| 9 | + | SplitLeft() error | |||
| 10 | + | SplitUp() error | |||
| 11 | + | SplitDown() error | |||
| 12 | + | StackPane() error | |||
| 13 | + | ToggleStack() error | |||
| 14 | + | ClosePane() error | |||
| 15 | + | FocusPaneLeft() error | |||
| 16 | + | FocusPaneRight() error | |||
| 17 | + | FocusPaneUp() error | |||
| 18 | + | FocusPaneDown() error | |||
| 8 | NextTab() error | 19 | NextTab() error | ||
| 9 | PrevTab() error | 20 | PrevTab() error | ||
| 10 | Detach() error | 21 | Detach() error | ||
M internal/usecase/command/registry.go +11 -0
| @@ -8,6 +8,17 @@ func Registry() []Command { | |||
| 8 | 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 9 | 9 | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | |
| 10 | 10 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 11 | + | {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, | |
| 12 | + | {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, | |
| 13 | + | {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | |
| 14 | + | {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | |
| 15 | + | {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | |
| 16 | + | {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | |
| 17 | + | {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | |
| 18 | + | {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | |
| 19 | + | {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | |
| 20 | + | {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, | |
| 21 | + | {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, | |
| 11 | 22 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 12 | 23 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 13 | 24 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| @@ -8,6 +8,17 @@ func Registry() []Command { | |||||
| 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | ||
| 9 | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | 9 | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | ||
| 10 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | 10 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | ||
| 11 | + | {Code: "SPR", Name: "Split right", Desc: "Split the focused pane to the right", Run: func(ctx Context) error { return ctx.SplitRight() }}, | |||
| 12 | + | {Code: "SPL", Name: "Split left", Desc: "Split the focused pane to the left", Run: func(ctx Context) error { return ctx.SplitLeft() }}, | |||
| 13 | + | {Code: "SPU", Name: "Split up", Desc: "Split the focused pane upward", Run: func(ctx Context) error { return ctx.SplitUp() }}, | |||
| 14 | + | {Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | |||
| 15 | + | {Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | |||
| 16 | + | {Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | |||
| 17 | + | {Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | |||
| 18 | + | {Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | |||
| 19 | + | {Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | |||
| 20 | + | {Code: "FPU", Name: "Focus pane up", Desc: "Focus the pane above", Run: func(ctx Context) error { return ctx.FocusPaneUp() }}, | |||
| 21 | + | {Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }}, | |||
| 11 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | 22 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | ||
| 12 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | 23 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | ||
| 13 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | 24 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | ||
M internal/usecase/command/registry_test.go +23 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 10 | 10 | commands := Registry() | |
| 11 | - | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 12 | 12 | ||
| 13 | 13 | if len(commands) != len(wantCodes) { | |
| 14 | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -54,6 +54,17 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 54 | 54 | {code: "CNT", want: "CreateTab"}, | |
| 55 | 55 | {code: "CNS", want: "CreateSession"}, | |
| 56 | 56 | {code: "CLT", want: "CloseTab"}, | |
| 57 | + | {code: "SPR", want: "SplitRight"}, | |
| 58 | + | {code: "SPL", want: "SplitLeft"}, | |
| 59 | + | {code: "SPU", want: "SplitUp"}, | |
| 60 | + | {code: "SPD", want: "SplitDown"}, | |
| 61 | + | {code: "STP", want: "StackPane"}, | |
| 62 | + | {code: "TST", want: "ToggleStack"}, | |
| 63 | + | {code: "CLP", want: "ClosePane"}, | |
| 64 | + | {code: "FPL", want: "FocusPaneLeft"}, | |
| 65 | + | {code: "FPR", want: "FocusPaneRight"}, | |
| 66 | + | {code: "FPU", want: "FocusPaneUp"}, | |
| 67 | + | {code: "FPD", want: "FocusPaneDown"}, | |
| 57 | 68 | {code: "NXT", want: "NextTab"}, | |
| 58 | 69 | {code: "PVT", want: "PrevTab"}, | |
| 59 | 70 | {code: "SSP", want: "OpenSessionPicker"}, | |
| @@ -99,6 +110,17 @@ func (s *spyContext) onlyCall() string { | |||
| 99 | 110 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | |
| 100 | 111 | func (s *spyContext) CreateSession() error { return s.record("CreateSession") } | |
| 101 | 112 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |
| 113 | + | func (s *spyContext) SplitRight() error { return s.record("SplitRight") } | |
| 114 | + | func (s *spyContext) SplitLeft() error { return s.record("SplitLeft") } | |
| 115 | + | func (s *spyContext) SplitUp() error { return s.record("SplitUp") } | |
| 116 | + | func (s *spyContext) SplitDown() error { return s.record("SplitDown") } | |
| 117 | + | func (s *spyContext) StackPane() error { return s.record("StackPane") } | |
| 118 | + | func (s *spyContext) ToggleStack() error { return s.record("ToggleStack") } | |
| 119 | + | func (s *spyContext) ClosePane() error { return s.record("ClosePane") } | |
| 120 | + | func (s *spyContext) FocusPaneLeft() error { return s.record("FocusPaneLeft") } | |
| 121 | + | func (s *spyContext) FocusPaneRight() error { return s.record("FocusPaneRight") } | |
| 122 | + | func (s *spyContext) FocusPaneUp() error { return s.record("FocusPaneUp") } | |
| 123 | + | func (s *spyContext) FocusPaneDown() error { return s.record("FocusPaneDown") } | |
| 102 | 124 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | |
| 103 | 125 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |
| 104 | 126 | func (s *spyContext) Detach() error { return s.record("Detach") } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | ||
| 10 | commands := Registry() | 10 | commands := Registry() | ||
| 11 | - | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} |
| 12 | 12 | ||||
| 13 | if len(commands) != len(wantCodes) { | 13 | if len(commands) != len(wantCodes) { | ||
| 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -54,6 +54,17 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 54 | {code: "CNT", want: "CreateTab"}, | 54 | {code: "CNT", want: "CreateTab"}, | ||
| 55 | {code: "CNS", want: "CreateSession"}, | 55 | {code: "CNS", want: "CreateSession"}, | ||
| 56 | {code: "CLT", want: "CloseTab"}, | 56 | {code: "CLT", want: "CloseTab"}, | ||
| 57 | + | {code: "SPR", want: "SplitRight"}, | |||
| 58 | + | {code: "SPL", want: "SplitLeft"}, | |||
| 59 | + | {code: "SPU", want: "SplitUp"}, | |||
| 60 | + | {code: "SPD", want: "SplitDown"}, | |||
| 61 | + | {code: "STP", want: "StackPane"}, | |||
| 62 | + | {code: "TST", want: "ToggleStack"}, | |||
| 63 | + | {code: "CLP", want: "ClosePane"}, | |||
| 64 | + | {code: "FPL", want: "FocusPaneLeft"}, | |||
| 65 | + | {code: "FPR", want: "FocusPaneRight"}, | |||
| 66 | + | {code: "FPU", want: "FocusPaneUp"}, | |||
| 67 | + | {code: "FPD", want: "FocusPaneDown"}, | |||
| 57 | {code: "NXT", want: "NextTab"}, | 68 | {code: "NXT", want: "NextTab"}, | ||
| 58 | {code: "PVT", want: "PrevTab"}, | 69 | {code: "PVT", want: "PrevTab"}, | ||
| 59 | {code: "SSP", want: "OpenSessionPicker"}, | 70 | {code: "SSP", want: "OpenSessionPicker"}, | ||
| @@ -99,6 +110,17 @@ func (s *spyContext) onlyCall() string { | |||||
| 99 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | 110 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | ||
| 100 | func (s *spyContext) CreateSession() error { return s.record("CreateSession") } | 111 | func (s *spyContext) CreateSession() error { return s.record("CreateSession") } | ||
| 101 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | 112 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | ||
| 113 | + | func (s *spyContext) SplitRight() error { return s.record("SplitRight") } | |||
| 114 | + | func (s *spyContext) SplitLeft() error { return s.record("SplitLeft") } | |||
| 115 | + | func (s *spyContext) SplitUp() error { return s.record("SplitUp") } | |||
| 116 | + | func (s *spyContext) SplitDown() error { return s.record("SplitDown") } | |||
| 117 | + | func (s *spyContext) StackPane() error { return s.record("StackPane") } | |||
| 118 | + | func (s *spyContext) ToggleStack() error { return s.record("ToggleStack") } | |||
| 119 | + | func (s *spyContext) ClosePane() error { return s.record("ClosePane") } | |||
| 120 | + | func (s *spyContext) FocusPaneLeft() error { return s.record("FocusPaneLeft") } | |||
| 121 | + | func (s *spyContext) FocusPaneRight() error { return s.record("FocusPaneRight") } | |||
| 122 | + | func (s *spyContext) FocusPaneUp() error { return s.record("FocusPaneUp") } | |||
| 123 | + | func (s *spyContext) FocusPaneDown() error { return s.record("FocusPaneDown") } | |||
| 102 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | 124 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | ||
| 103 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | 125 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | ||
| 104 | func (s *spyContext) Detach() error { return s.record("Detach") } | 126 | func (s *spyContext) Detach() error { return s.record("Detach") } | ||
M internal/usecase/daemon/attention_test.go +3 -2
| @@ -156,8 +156,9 @@ func (p *scriptPTY) Close() error { | |||
| 156 | 156 | p.once.Do(func() { close(p.closed) }) | |
| 157 | 157 | return nil | |
| 158 | 158 | } | |
| 159 | - | func (p *scriptPTY) Resize(domain.Size) error { return nil } | |
| 160 | - | func (p *scriptPTY) Pid() int { return 4242 } | |
| 159 | + | func (p *scriptPTY) Resize(domain.Size) error { return nil } | |
| 160 | + | func (p *scriptPTY) Pid() int { return 4242 } | |
| 161 | + | func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil } | |
| 161 | 162 | ||
| 162 | 163 | func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | |
| 163 | 164 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| @@ -156,8 +156,9 @@ func (p *scriptPTY) Close() error { | |||||
| 156 | p.once.Do(func() { close(p.closed) }) | 156 | p.once.Do(func() { close(p.closed) }) | ||
| 157 | return nil | 157 | return nil | ||
| 158 | } | 158 | } | ||
| 159 | - | func (p *scriptPTY) Resize(domain.Size) error { return nil } | 159 | + | func (p *scriptPTY) Resize(domain.Size) error { return nil } |
| 160 | - | func (p *scriptPTY) Pid() int { return 4242 } | 160 | + | func (p *scriptPTY) Pid() int { return 4242 } |
| 161 | + | func (p *scriptPTY) ForegroundPgid() (int, error) { return 4242, nil } | |||
| 161 | 162 | ||||
| 162 | func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | 163 | func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) { | ||
| 163 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 164 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
M internal/usecase/daemon/client.go +43 -15
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -254,8 +253,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 254 | 253 | sess.mu.Unlock() | |
| 255 | 254 | for _, tb := range tabs { | |
| 256 | 255 | tb.mu.Lock() | |
| 257 | - | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 256 | + | panes := tb.panesSnapshot() | |
| 258 | 257 | tb.mu.Unlock() | |
| 258 | + | for _, p := range panes { | |
| 259 | + | p.mu.Lock() | |
| 260 | + | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 261 | + | p.mu.Unlock() | |
| 262 | + | } | |
| 259 | 263 | } | |
| 260 | 264 | return ac, old | |
| 261 | 265 | } | |
| @@ -281,8 +285,13 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 281 | 285 | sess.mu.Unlock() | |
| 282 | 286 | for _, tb := range tabs { | |
| 283 | 287 | tb.mu.Lock() | |
| 284 | - | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 288 | + | panes := tb.panesSnapshot() | |
| 285 | 289 | tb.mu.Unlock() | |
| 290 | + | for _, p := range panes { | |
| 291 | + | p.mu.Lock() | |
| 292 | + | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |
| 293 | + | p.mu.Unlock() | |
| 294 | + | } | |
| 286 | 295 | } | |
| 287 | 296 | } | |
| 288 | 297 | ||
| @@ -410,8 +419,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 410 | 419 | ac.setTheme(t) | |
| 411 | 420 | for _, tb := range tabs { | |
| 412 | 421 | tb.mu.Lock() | |
| 413 | - | tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |
| 422 | + | panes := tb.panesSnapshot() | |
| 414 | 423 | tb.mu.Unlock() | |
| 424 | + | for _, p := range panes { | |
| 425 | + | p.mu.Lock() | |
| 426 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |
| 427 | + | p.mu.Unlock() | |
| 428 | + | } | |
| 415 | 429 | } | |
| 416 | 430 | d.paint(sess, ac, true) | |
| 417 | 431 | } | |
| @@ -421,7 +435,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 421 | 435 | return | |
| 422 | 436 | } | |
| 423 | 437 | tbSize := tabSize(sz) | |
| 424 | - | ac.size = sz | |
| 438 | + | if ac != nil { | |
| 439 | + | ac.size = sz | |
| 440 | + | } | |
| 425 | 441 | ||
| 426 | 442 | sess.mu.Lock() | |
| 427 | 443 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -431,15 +447,27 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 431 | 447 | } | |
| 432 | 448 | ||
| 433 | 449 | for _, tb := range tabs { | |
| 434 | - | if err := tb.pty.Resize(tbSize); err != nil { | |
| 435 | - | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | |
| 436 | - | } | |
| 437 | 450 | tb.mu.Lock() | |
| 438 | - | tb.screen.Resize(tbSize.Cols, tbSize.Rows) | |
| 439 | 451 | tb.size = tbSize | |
| 452 | + | if tb.tree == nil { | |
| 453 | + | p := tb.focusedPane() | |
| 454 | + | if p != nil { | |
| 455 | + | if err := p.pty.Resize(tbSize); err != nil { | |
| 456 | + | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | |
| 457 | + | } | |
| 458 | + | p.mu.Lock() | |
| 459 | + | p.screen.Resize(tbSize.Cols, tbSize.Rows) | |
| 460 | + | p.rect = domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows} | |
| 461 | + | p.mu.Unlock() | |
| 462 | + | } | |
| 463 | + | } else { | |
| 464 | + | d.applyLayoutLocked(tb) | |
| 465 | + | } | |
| 440 | 466 | tb.mu.Unlock() | |
| 441 | 467 | } | |
| 442 | - | d.paint(sess, ac, true) | |
| 468 | + | if ac != nil { | |
| 469 | + | d.paint(sess, ac, true) | |
| 470 | + | } | |
| 443 | 471 | } | |
| 444 | 472 | ||
| 445 | 473 | // detachOnSendError drops a client whose transport failed. Like every other | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| @@ -254,8 +253,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 254 | sess.mu.Unlock() | 253 | sess.mu.Unlock() | ||
| 255 | for _, tb := range tabs { | 254 | for _, tb := range tabs { | ||
| 256 | tb.mu.Lock() | 255 | tb.mu.Lock() | ||
| 257 | - | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | 256 | + | panes := tb.panesSnapshot() |
| 258 | tb.mu.Unlock() | 257 | tb.mu.Unlock() | ||
| 258 | + | for _, p := range panes { | |||
| 259 | + | p.mu.Lock() | |||
| 260 | + | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 261 | + | p.mu.Unlock() | |||
| 262 | + | } | |||
| 259 | } | 263 | } | ||
| 260 | return ac, old | 264 | return ac, old | ||
| 261 | } | 265 | } | ||
| @@ -281,8 +285,13 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||||
| 281 | sess.mu.Unlock() | 285 | sess.mu.Unlock() | ||
| 282 | for _, tb := range tabs { | 286 | for _, tb := range tabs { | ||
| 283 | tb.mu.Lock() | 287 | tb.mu.Lock() | ||
| 284 | - | tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | 288 | + | panes := tb.panesSnapshot() |
| 285 | tb.mu.Unlock() | 289 | tb.mu.Unlock() | ||
| 290 | + | for _, p := range panes { | |||
| 291 | + | p.mu.Lock() | |||
| 292 | + | p.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false) | |||
| 293 | + | p.mu.Unlock() | |||
| 294 | + | } | |||
| 286 | } | 295 | } | ||
| 287 | } | 296 | } | ||
| 288 | 297 | ||||
| @@ -410,8 +419,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||||
| 410 | ac.setTheme(t) | 419 | ac.setTheme(t) | ||
| 411 | for _, tb := range tabs { | 420 | for _, tb := range tabs { | ||
| 412 | tb.mu.Lock() | 421 | tb.mu.Lock() | ||
| 413 | - | tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | 422 | + | panes := tb.panesSnapshot() |
| 414 | tb.mu.Unlock() | 423 | tb.mu.Unlock() | ||
| 424 | + | for _, p := range panes { | |||
| 425 | + | p.mu.Lock() | |||
| 426 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors) | |||
| 427 | + | p.mu.Unlock() | |||
| 428 | + | } | |||
| 415 | } | 429 | } | ||
| 416 | d.paint(sess, ac, true) | 430 | d.paint(sess, ac, true) | ||
| 417 | } | 431 | } | ||
| @@ -421,7 +435,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 421 | return | 435 | return | ||
| 422 | } | 436 | } | ||
| 423 | tbSize := tabSize(sz) | 437 | tbSize := tabSize(sz) | ||
| 424 | - | ac.size = sz | 438 | + | if ac != nil { |
| 439 | + | ac.size = sz | |||
| 440 | + | } | |||
| 425 | 441 | ||||
| 426 | sess.mu.Lock() | 442 | sess.mu.Lock() | ||
| 427 | tabs := append([]*tab(nil), sess.tabs...) | 443 | tabs := append([]*tab(nil), sess.tabs...) | ||
| @@ -431,15 +447,27 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 431 | } | 447 | } | ||
| 432 | 448 | ||||
| 433 | for _, tb := range tabs { | 449 | for _, tb := range tabs { | ||
| 434 | - | if err := tb.pty.Resize(tbSize); err != nil { | |||
| 435 | - | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | |||
| 436 | - | } | |||
| 437 | tb.mu.Lock() | 450 | tb.mu.Lock() | ||
| 438 | - | tb.screen.Resize(tbSize.Cols, tbSize.Rows) | |||
| 439 | tb.size = tbSize | 451 | tb.size = tbSize | ||
| 452 | + | if tb.tree == nil { | |||
| 453 | + | p := tb.focusedPane() | |||
| 454 | + | if p != nil { | |||
| 455 | + | if err := p.pty.Resize(tbSize); err != nil { | |||
| 456 | + | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | |||
| 457 | + | } | |||
| 458 | + | p.mu.Lock() | |||
| 459 | + | p.screen.Resize(tbSize.Cols, tbSize.Rows) | |||
| 460 | + | p.rect = domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows} | |||
| 461 | + | p.mu.Unlock() | |||
| 462 | + | } | |||
| 463 | + | } else { | |||
| 464 | + | d.applyLayoutLocked(tb) | |||
| 465 | + | } | |||
| 440 | tb.mu.Unlock() | 466 | tb.mu.Unlock() | ||
| 441 | } | 467 | } | ||
| 442 | - | d.paint(sess, ac, true) | 468 | + | if ac != nil { |
| 469 | + | d.paint(sess, ac, true) | |||
| 470 | + | } | |||
| 443 | } | 471 | } | ||
| 444 | 472 | ||||
| 445 | // detachOnSendError drops a client whose transport failed. Like every other | 473 | // detachOnSendError drops a client whose transport failed. Like every other | ||
M internal/usecase/daemon/copymode.go +61 -23
| @@ -17,20 +17,21 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| 29 | 28 | "strconv" | |
| 30 | 29 | ||
| 30 | + | "github.com/bnema/vev/internal/domain" | |
| 31 | 31 | "github.com/bnema/vev/internal/ports" | |
| 32 | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 34 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 35 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 35 | 36 | "github.com/bnema/vev/pkg/renderer" | |
| 36 | 37 | ) | |
| @@ -42,17 +43,23 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | tb.mu.Lock() | |
| 46 | + | p := tb.focusedPane() | |
| 47 | + | tb.mu.Unlock() | |
| 48 | + | if p == nil { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | p.mu.Lock() | |
| 45 | 52 | ac.copyMu.Lock() | |
| 46 | 53 | if ac.copyMode == nil { | |
| 47 | 54 | ac.copyMu.Unlock() | |
| 48 | - | tb.mu.Unlock() | |
| 55 | + | p.mu.Unlock() | |
| 49 | 56 | return | |
| 50 | 57 | } | |
| 51 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 58 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 52 | 59 | if delta > 0 && ac.copyMode.AtBottom(snap) { | |
| 53 | 60 | ac.copyMode = nil | |
| 54 | 61 | ac.copyMu.Unlock() | |
| 55 | - | tb.mu.Unlock() | |
| 62 | + | p.mu.Unlock() | |
| 56 | 63 | d.paint(sess, ac, true) | |
| 57 | 64 | return | |
| 58 | 65 | } | |
| @@ -62,7 +69,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 62 | 69 | ac.copyMode = nil | |
| 63 | 70 | } | |
| 64 | 71 | ac.copyMu.Unlock() | |
| 65 | - | tb.mu.Unlock() | |
| 72 | + | p.mu.Unlock() | |
| 66 | 73 | ||
| 67 | 74 | d.paint(sess, ac, true) | |
| 68 | 75 | } | |
| @@ -73,8 +80,14 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 73 | 80 | return | |
| 74 | 81 | } | |
| 75 | 82 | tb.mu.Lock() | |
| 76 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 83 | + | p := tb.focusedPane() | |
| 77 | 84 | tb.mu.Unlock() | |
| 85 | + | if p == nil { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | p.mu.Lock() | |
| 89 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 90 | + | p.mu.Unlock() | |
| 78 | 91 | ac.copyMu.Lock() | |
| 79 | 92 | ac.copyMode = scopy.NewMode(snap) | |
| 80 | 93 | ac.copyPressRowValid = false | |
| @@ -94,23 +107,29 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 94 | 107 | } | |
| 95 | 108 | ||
| 96 | 109 | tb.mu.Lock() | |
| 97 | - | if ev.Row >= tb.screen.Frame.Height { | |
| 110 | + | p := tb.focusedPane() | |
| 111 | + | tb.mu.Unlock() | |
| 112 | + | if p == nil { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | p.mu.Lock() | |
| 116 | + | if ev.Row >= p.screen.Frame.Height { | |
| 98 | 117 | if ev.Type == mouse.Press { | |
| 99 | 118 | ac.copyMu.Lock() | |
| 100 | 119 | ac.copyPressRowValid = false | |
| 101 | 120 | ac.copyDragging = false | |
| 102 | 121 | ac.copyMu.Unlock() | |
| 103 | 122 | } | |
| 104 | - | tb.mu.Unlock() | |
| 123 | + | p.mu.Unlock() | |
| 105 | 124 | return | |
| 106 | 125 | } | |
| 107 | 126 | ac.copyMu.Lock() | |
| 108 | 127 | if ac.copyMode == nil { | |
| 109 | 128 | ac.copyMu.Unlock() | |
| 110 | - | tb.mu.Unlock() | |
| 129 | + | p.mu.Unlock() | |
| 111 | 130 | return | |
| 112 | 131 | } | |
| 113 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 132 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 114 | 133 | absRow := ac.copyMode.ViewportTop + ev.Row | |
| 115 | 134 | changed := false | |
| 116 | 135 | switch ev.Type { | |
| @@ -134,7 +153,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 134 | 153 | // Button release intentionally has no visual effect. | |
| 135 | 154 | } | |
| 136 | 155 | ac.copyMu.Unlock() | |
| 137 | - | tb.mu.Unlock() | |
| 156 | + | p.mu.Unlock() | |
| 138 | 157 | ||
| 139 | 158 | if changed { | |
| 140 | 159 | d.paint(sess, ac, true) | |
| @@ -152,12 +171,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 152 | 171 | } | |
| 153 | 172 | ||
| 154 | 173 | tb.mu.Lock() | |
| 174 | + | p := tb.focusedPane() | |
| 175 | + | tb.mu.Unlock() | |
| 176 | + | if p == nil { | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | p.mu.Lock() | |
| 155 | 180 | ac.copyMu.Lock() | |
| 156 | 181 | if ac.copyMode == nil { | |
| 157 | 182 | ac.copyPending = nil | |
| 158 | 183 | d.stopCopyPendingTimerLocked(ac) | |
| 159 | 184 | ac.copyMu.Unlock() | |
| 160 | - | tb.mu.Unlock() | |
| 185 | + | p.mu.Unlock() | |
| 161 | 186 | return | |
| 162 | 187 | } | |
| 163 | 188 | if len(ac.copyPending) > 0 { | |
| @@ -168,7 +193,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 168 | 193 | data = combined | |
| 169 | 194 | ac.copyPending = nil | |
| 170 | 195 | } | |
| 171 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 196 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 172 | 197 | changed := false | |
| 173 | 198 | copyOut := false | |
| 174 | 199 | exit := false | |
| @@ -222,7 +247,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 222 | 247 | ac.copyMode = nil | |
| 223 | 248 | } | |
| 224 | 249 | ac.copyMu.Unlock() | |
| 225 | - | tb.mu.Unlock() | |
| 250 | + | p.mu.Unlock() | |
| 226 | 251 | ||
| 227 | 252 | if copyOut && text != "" { | |
| 228 | 253 | chunks := scopy.OSC52(text) | |
| @@ -309,14 +334,27 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 309 | 334 | ||
| 310 | 335 | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 311 | 336 | styles := newThemeStyles(bars.theme) | |
| 312 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 337 | + | p := tb.focusedPane() | |
| 338 | + | pl, ok := focusedPlacementLocked(tb) | |
| 339 | + | if !ok { | |
| 340 | + | pl = layout.Placement{Content: domain.Rect{Width: p.screen.Frame.Width, Height: p.screen.Frame.Height}} | |
| 341 | + | } | |
| 342 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 313 | 343 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | |
| 314 | - | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 344 | + | width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height | |
| 345 | + | if len(tb.panes) > 1 && tb.size.Valid() { | |
| 346 | + | width, screenRows = tb.size.Cols, tb.size.Rows | |
| 347 | + | } | |
| 315 | 348 | frame := renderer.NewFrame(width, screenRows+2) | |
| 316 | 349 | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles) | |
| 350 | + | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |
| 317 | 351 | for y := range screenRows { | |
| 318 | - | copy(frame.Row(y+1), copyFrame.Row(y)) | |
| 352 | + | copy(frame.Row(y+1), base.Row(y)) | |
| 353 | + | } | |
| 354 | + | for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ { | |
| 355 | + | copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)]) | |
| 319 | 356 | } | |
| 320 | - | copy(frame.Row(screenRows+1), copyFrame.Row(screenRows)) | |
| 357 | + | statusY := screenRows + 1 | |
| 358 | + | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)]) | |
| 321 | 359 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 322 | 360 | } | |
| @@ -17,20 +17,21 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| 29 | "strconv" | 28 | "strconv" | ||
| 30 | 29 | ||||
| 30 | + | "github.com/bnema/vev/internal/domain" | |||
| 31 | "github.com/bnema/vev/internal/ports" | 31 | "github.com/bnema/vev/internal/ports" | ||
| 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 33 | "github.com/bnema/vev/internal/usecase/keys" | 33 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 34 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 34 | "github.com/bnema/vev/internal/usecase/mouse" | 35 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 35 | "github.com/bnema/vev/pkg/renderer" | 36 | "github.com/bnema/vev/pkg/renderer" | ||
| 36 | ) | 37 | ) | ||
| @@ -42,17 +43,23 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | tb.mu.Lock() | 45 | tb.mu.Lock() | ||
| 46 | + | p := tb.focusedPane() | |||
| 47 | + | tb.mu.Unlock() | |||
| 48 | + | if p == nil { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | p.mu.Lock() | |||
| 45 | ac.copyMu.Lock() | 52 | ac.copyMu.Lock() | ||
| 46 | if ac.copyMode == nil { | 53 | if ac.copyMode == nil { | ||
| 47 | ac.copyMu.Unlock() | 54 | ac.copyMu.Unlock() | ||
| 48 | - | tb.mu.Unlock() | 55 | + | p.mu.Unlock() |
| 49 | return | 56 | return | ||
| 50 | } | 57 | } | ||
| 51 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 58 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) |
| 52 | if delta > 0 && ac.copyMode.AtBottom(snap) { | 59 | if delta > 0 && ac.copyMode.AtBottom(snap) { | ||
| 53 | ac.copyMode = nil | 60 | ac.copyMode = nil | ||
| 54 | ac.copyMu.Unlock() | 61 | ac.copyMu.Unlock() | ||
| 55 | - | tb.mu.Unlock() | 62 | + | p.mu.Unlock() |
| 56 | d.paint(sess, ac, true) | 63 | d.paint(sess, ac, true) | ||
| 57 | return | 64 | return | ||
| 58 | } | 65 | } | ||
| @@ -62,7 +69,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 62 | ac.copyMode = nil | 69 | ac.copyMode = nil | ||
| 63 | } | 70 | } | ||
| 64 | ac.copyMu.Unlock() | 71 | ac.copyMu.Unlock() | ||
| 65 | - | tb.mu.Unlock() | 72 | + | p.mu.Unlock() |
| 66 | 73 | ||||
| 67 | d.paint(sess, ac, true) | 74 | d.paint(sess, ac, true) | ||
| 68 | } | 75 | } | ||
| @@ -73,8 +80,14 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 73 | return | 80 | return | ||
| 74 | } | 81 | } | ||
| 75 | tb.mu.Lock() | 82 | tb.mu.Lock() | ||
| 76 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 83 | + | p := tb.focusedPane() |
| 77 | tb.mu.Unlock() | 84 | tb.mu.Unlock() | ||
| 85 | + | if p == nil { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | p.mu.Lock() | |||
| 89 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |||
| 90 | + | p.mu.Unlock() | |||
| 78 | ac.copyMu.Lock() | 91 | ac.copyMu.Lock() | ||
| 79 | ac.copyMode = scopy.NewMode(snap) | 92 | ac.copyMode = scopy.NewMode(snap) | ||
| 80 | ac.copyPressRowValid = false | 93 | ac.copyPressRowValid = false | ||
| @@ -94,23 +107,29 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 94 | } | 107 | } | ||
| 95 | 108 | ||||
| 96 | tb.mu.Lock() | 109 | tb.mu.Lock() | ||
| 97 | - | if ev.Row >= tb.screen.Frame.Height { | 110 | + | p := tb.focusedPane() |
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | if p == nil { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | p.mu.Lock() | |||
| 116 | + | if ev.Row >= p.screen.Frame.Height { | |||
| 98 | if ev.Type == mouse.Press { | 117 | if ev.Type == mouse.Press { | ||
| 99 | ac.copyMu.Lock() | 118 | ac.copyMu.Lock() | ||
| 100 | ac.copyPressRowValid = false | 119 | ac.copyPressRowValid = false | ||
| 101 | ac.copyDragging = false | 120 | ac.copyDragging = false | ||
| 102 | ac.copyMu.Unlock() | 121 | ac.copyMu.Unlock() | ||
| 103 | } | 122 | } | ||
| 104 | - | tb.mu.Unlock() | 123 | + | p.mu.Unlock() |
| 105 | return | 124 | return | ||
| 106 | } | 125 | } | ||
| 107 | ac.copyMu.Lock() | 126 | ac.copyMu.Lock() | ||
| 108 | if ac.copyMode == nil { | 127 | if ac.copyMode == nil { | ||
| 109 | ac.copyMu.Unlock() | 128 | ac.copyMu.Unlock() | ||
| 110 | - | tb.mu.Unlock() | 129 | + | p.mu.Unlock() |
| 111 | return | 130 | return | ||
| 112 | } | 131 | } | ||
| 113 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 132 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) |
| 114 | absRow := ac.copyMode.ViewportTop + ev.Row | 133 | absRow := ac.copyMode.ViewportTop + ev.Row | ||
| 115 | changed := false | 134 | changed := false | ||
| 116 | switch ev.Type { | 135 | switch ev.Type { | ||
| @@ -134,7 +153,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 134 | // Button release intentionally has no visual effect. | 153 | // Button release intentionally has no visual effect. | ||
| 135 | } | 154 | } | ||
| 136 | ac.copyMu.Unlock() | 155 | ac.copyMu.Unlock() | ||
| 137 | - | tb.mu.Unlock() | 156 | + | p.mu.Unlock() |
| 138 | 157 | ||||
| 139 | if changed { | 158 | if changed { | ||
| 140 | d.paint(sess, ac, true) | 159 | d.paint(sess, ac, true) | ||
| @@ -152,12 +171,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 152 | } | 171 | } | ||
| 153 | 172 | ||||
| 154 | tb.mu.Lock() | 173 | tb.mu.Lock() | ||
| 174 | + | p := tb.focusedPane() | |||
| 175 | + | tb.mu.Unlock() | |||
| 176 | + | if p == nil { | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | p.mu.Lock() | |||
| 155 | ac.copyMu.Lock() | 180 | ac.copyMu.Lock() | ||
| 156 | if ac.copyMode == nil { | 181 | if ac.copyMode == nil { | ||
| 157 | ac.copyPending = nil | 182 | ac.copyPending = nil | ||
| 158 | d.stopCopyPendingTimerLocked(ac) | 183 | d.stopCopyPendingTimerLocked(ac) | ||
| 159 | ac.copyMu.Unlock() | 184 | ac.copyMu.Unlock() | ||
| 160 | - | tb.mu.Unlock() | 185 | + | p.mu.Unlock() |
| 161 | return | 186 | return | ||
| 162 | } | 187 | } | ||
| 163 | if len(ac.copyPending) > 0 { | 188 | if len(ac.copyPending) > 0 { | ||
| @@ -168,7 +193,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 168 | data = combined | 193 | data = combined | ||
| 169 | ac.copyPending = nil | 194 | ac.copyPending = nil | ||
| 170 | } | 195 | } | ||
| 171 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 196 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) |
| 172 | changed := false | 197 | changed := false | ||
| 173 | copyOut := false | 198 | copyOut := false | ||
| 174 | exit := false | 199 | exit := false | ||
| @@ -222,7 +247,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 222 | ac.copyMode = nil | 247 | ac.copyMode = nil | ||
| 223 | } | 248 | } | ||
| 224 | ac.copyMu.Unlock() | 249 | ac.copyMu.Unlock() | ||
| 225 | - | tb.mu.Unlock() | 250 | + | p.mu.Unlock() |
| 226 | 251 | ||||
| 227 | if copyOut && text != "" { | 252 | if copyOut && text != "" { | ||
| 228 | chunks := scopy.OSC52(text) | 253 | chunks := scopy.OSC52(text) | ||
| @@ -309,14 +334,27 @@ func isCopyEscapePrefix(data []byte) bool { | |||||
| 309 | 334 | ||||
| 310 | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | 335 | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | ||
| 311 | styles := newThemeStyles(bars.theme) | 336 | styles := newThemeStyles(bars.theme) | ||
| 312 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 337 | + | p := tb.focusedPane() |
| 338 | + | pl, ok := focusedPlacementLocked(tb) | |||
| 339 | + | if !ok { | |||
| 340 | + | pl = layout.Placement{Content: domain.Rect{Width: p.screen.Frame.Width, Height: p.screen.Frame.Height}} | |||
| 341 | + | } | |||
| 342 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |||
| 313 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | 343 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | ||
| 314 | - | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | 344 | + | width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height |
| 345 | + | if len(tb.panes) > 1 && tb.size.Valid() { | |||
| 346 | + | width, screenRows = tb.size.Cols, tb.size.Rows | |||
| 347 | + | } | |||
| 315 | frame := renderer.NewFrame(width, screenRows+2) | 348 | frame := renderer.NewFrame(width, screenRows+2) | ||
| 316 | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles) | 349 | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles) | ||
| 350 | + | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |||
| 317 | for y := range screenRows { | 351 | for y := range screenRows { | ||
| 318 | - | copy(frame.Row(y+1), copyFrame.Row(y)) | 352 | + | copy(frame.Row(y+1), base.Row(y)) |
| 353 | + | } | |||
| 354 | + | for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ { | |||
| 355 | + | copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)]) | |||
| 319 | } | 356 | } | ||
| 320 | - | copy(frame.Row(screenRows+1), copyFrame.Row(screenRows)) | 357 | + | statusY := screenRows + 1 |
| 358 | + | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)]) | |||
| 321 | return frame, []renderer.Damage{renderer.FullRedraw()} | 359 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 322 | } | 360 | } | ||
M internal/usecase/daemon/copymode_test.go +8 -2
| @@ -168,8 +168,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 168 | 168 | return false | |
| 169 | 169 | } | |
| 170 | 170 | win.mu.Lock() | |
| 171 | - | defer win.mu.Unlock() | |
| 172 | - | return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12 | |
| 171 | + | p := win.focusedPane() | |
| 172 | + | win.mu.Unlock() | |
| 173 | + | if p == nil { | |
| 174 | + | return false | |
| 175 | + | } | |
| 176 | + | p.mu.Lock() | |
| 177 | + | defer p.mu.Unlock() | |
| 178 | + | return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12 | |
| 173 | 179 | }, 2*time.Second, 5*time.Millisecond) | |
| 174 | 180 | ||
| 175 | 181 | sess := firstSession(d) | |
| @@ -168,8 +168,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 168 | return false | 168 | return false | ||
| 169 | } | 169 | } | ||
| 170 | win.mu.Lock() | 170 | win.mu.Lock() | ||
| 171 | - | defer win.mu.Unlock() | 171 | + | p := win.focusedPane() |
| 172 | - | return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12 | 172 | + | win.mu.Unlock() |
| 173 | + | if p == nil { | |||
| 174 | + | return false | |||
| 175 | + | } | |||
| 176 | + | p.mu.Lock() | |||
| 177 | + | defer p.mu.Unlock() | |||
| 178 | + | return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12 | |||
| 173 | }, 2*time.Second, 5*time.Millisecond) | 179 | }, 2*time.Second, 5*time.Millisecond) | ||
| 174 | 180 | ||||
| 175 | sess := firstSession(d) | 181 | sess := firstSession(d) | ||
M internal/usecase/daemon/daemon.go +10 -6
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -100,6 +99,7 @@ type Daemon struct { | |||
| 100 | 99 | persist *persist.Persister | |
| 101 | 100 | persistEnabled bool | |
| 102 | 101 | procCwd func(int) (string, error) | |
| 102 | + | procComm func(int) (string, error) | |
| 103 | 103 | ||
| 104 | 104 | serveCtx context.Context | |
| 105 | 105 | serveCancel context.CancelFunc | |
| @@ -176,6 +176,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 176 | 176 | shell: shell, | |
| 177 | 177 | persist: persist.New(nil), | |
| 178 | 178 | procCwd: platform.ProcessCwd, | |
| 179 | + | procComm: platform.ProcessComm, | |
| 179 | 180 | done: make(chan struct{}), | |
| 180 | 181 | animWake: make(chan struct{}, 1), | |
| 181 | 182 | } | |
| @@ -188,6 +189,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 188 | 189 | if d.procCwd == nil { | |
| 189 | 190 | d.procCwd = platform.ProcessCwd | |
| 190 | 191 | } | |
| 192 | + | if d.procComm == nil { | |
| 193 | + | d.procComm = platform.ProcessComm | |
| 194 | + | } | |
| 191 | 195 | if records, err := d.persist.LoadAll(); err != nil { | |
| 192 | 196 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 193 | 197 | } else { | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| @@ -100,6 +99,7 @@ type Daemon struct { | |||||
| 100 | persist *persist.Persister | 99 | persist *persist.Persister | ||
| 101 | persistEnabled bool | 100 | persistEnabled bool | ||
| 102 | procCwd func(int) (string, error) | 101 | procCwd func(int) (string, error) | ||
| 102 | + | procComm func(int) (string, error) | |||
| 103 | 103 | ||||
| 104 | serveCtx context.Context | 104 | serveCtx context.Context | ||
| 105 | serveCancel context.CancelFunc | 105 | serveCancel context.CancelFunc | ||
| @@ -176,6 +176,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 176 | shell: shell, | 176 | shell: shell, | ||
| 177 | persist: persist.New(nil), | 177 | persist: persist.New(nil), | ||
| 178 | procCwd: platform.ProcessCwd, | 178 | procCwd: platform.ProcessCwd, | ||
| 179 | + | procComm: platform.ProcessComm, | |||
| 179 | done: make(chan struct{}), | 180 | done: make(chan struct{}), | ||
| 180 | animWake: make(chan struct{}, 1), | 181 | animWake: make(chan struct{}, 1), | ||
| 181 | } | 182 | } | ||
| @@ -188,6 +189,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 188 | if d.procCwd == nil { | 189 | if d.procCwd == nil { | ||
| 189 | d.procCwd = platform.ProcessCwd | 190 | d.procCwd = platform.ProcessCwd | ||
| 190 | } | 191 | } | ||
| 192 | + | if d.procComm == nil { | |||
| 193 | + | d.procComm = platform.ProcessComm | |||
| 194 | + | } | |||
| 191 | if records, err := d.persist.LoadAll(); err != nil { | 195 | if records, err := d.persist.LoadAll(); err != nil { | ||
| 192 | d.log.Warn("loading persisted sessions failed", "err", err) | 196 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 193 | } else { | 197 | } else { | ||
M internal/usecase/daemon/daemon_test.go +6 -1
| @@ -243,7 +243,12 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 243 | 243 | tabs := make([]*tab, 0, len(ptys)) | |
| 244 | 244 | for _, p := range ptys { | |
| 245 | 245 | wctx, wcancel := context.WithCancel(sctx) | |
| 246 | - | tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | |
| 246 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 247 | + | tb.ctx, tb.cancel = wctx, wcancel | |
| 248 | + | for _, pane := range tb.panes { | |
| 249 | + | pane.ctx, pane.cancel = wctx, wcancel | |
| 250 | + | } | |
| 251 | + | tabs = append(tabs, tb) | |
| 247 | 252 | } | |
| 248 | 253 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 249 | 254 | ac.setSession(sess) | |
| @@ -243,7 +243,12 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 243 | tabs := make([]*tab, 0, len(ptys)) | 243 | tabs := make([]*tab, 0, len(ptys)) | ||
| 244 | for _, p := range ptys { | 244 | for _, p := range ptys { | ||
| 245 | wctx, wcancel := context.WithCancel(sctx) | 245 | wctx, wcancel := context.WithCancel(sctx) | ||
| 246 | - | tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | 246 | + | tb := newTab(p, domain.Size{Cols: 80, Rows: 23}) |
| 247 | + | tb.ctx, tb.cancel = wctx, wcancel | |||
| 248 | + | for _, pane := range tb.panes { | |||
| 249 | + | pane.ctx, pane.cancel = wctx, wcancel | |||
| 250 | + | } | |||
| 251 | + | tabs = append(tabs, tb) | |||
| 247 | } | 252 | } | ||
| 248 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | 253 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | ||
| 249 | ac.setSession(sess) | 254 | ac.setSession(sess) | ||
M internal/usecase/daemon/input.go +109 -20
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -31,6 +30,7 @@ import ( | |||
| 31 | 30 | ||
| 32 | 31 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 32 | "github.com/bnema/vev/internal/usecase/keys" | |
| 33 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 34 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 35 | 35 | ) | |
| 36 | 36 | ||
| @@ -73,6 +73,23 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | if ac.copyModeActive() { | |
| 76 | + | if ev.Button == mouse.Left { | |
| 77 | + | tb.mu.Lock() | |
| 78 | + | p := tb.focusedPane() | |
| 79 | + | var pl layout.Placement | |
| 80 | + | var ok bool | |
| 81 | + | multi := len(tb.panes) > 1 | |
| 82 | + | if p != nil { | |
| 83 | + | pl, ok = focusedPlacementLocked(tb) | |
| 84 | + | } | |
| 85 | + | tb.mu.Unlock() | |
| 86 | + | if ok && multi { | |
| 87 | + | if !pointInRect(ev.Col, ev.Row-1, pl.Content) { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 91 | + | } | |
| 92 | + | } | |
| 76 | 93 | switch ev.Button { | |
| 77 | 94 | case mouse.Left: | |
| 78 | 95 | d.copyMouse(sess, ac, ev) | |
| @@ -85,20 +102,69 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 85 | 102 | } | |
| 86 | 103 | ||
| 87 | 104 | tb.mu.Lock() | |
| 88 | - | childRows := tb.screen.Frame.Height | |
| 89 | - | mouseMode, mouseSGR := tb.screen.MouseMode() | |
| 90 | - | altScreen := tb.screen.AltScreenActive() | |
| 105 | + | contentRow := ev.Row - 1 | |
| 106 | + | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | |
| 107 | + | if hit && pl.Collapsed && pointInRect(ev.Col, contentRow, pl.TitleBar) { | |
| 108 | + | oldFocus := tb.tree.Focus | |
| 109 | + | focusPlacementLocked(tb, pl.ID) | |
| 110 | + | d.applyLayoutLocked(tb) | |
| 111 | + | tb.mu.Unlock() | |
| 112 | + | if pl.ID != oldFocus { | |
| 113 | + | d.exitCopyMode(ac) | |
| 114 | + | d.refreshPaneTitle(sess, pl.ID) | |
| 115 | + | } | |
| 116 | + | d.paint(sess, ac, true) | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | var p *pane | |
| 120 | + | translated := false | |
| 121 | + | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | |
| 122 | + | oldFocus := tb.tree.Focus | |
| 123 | + | focusPlacementLocked(tb, pl.ID) | |
| 124 | + | d.applyLayoutLocked(tb) | |
| 125 | + | p = tb.panes[pl.ID] | |
| 126 | + | if len(tb.panes) == 1 { | |
| 127 | + | p = tb.focusedPane() | |
| 128 | + | } | |
| 129 | + | tb.mu.Unlock() | |
| 130 | + | if p == nil { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | if pl.ID != oldFocus { | |
| 134 | + | d.exitCopyMode(ac) | |
| 135 | + | d.refreshPaneTitle(sess, pl.ID) | |
| 136 | + | d.paint(sess, ac, true) | |
| 137 | + | } | |
| 138 | + | if len(tb.panes) > 1 { | |
| 139 | + | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |
| 140 | + | translated = true | |
| 141 | + | } | |
| 142 | + | } else { | |
| 143 | + | p = tb.focusedPane() | |
| 144 | + | tb.mu.Unlock() | |
| 145 | + | if p == nil { | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | p.mu.Lock() | |
| 150 | + | childRows := p.screen.Frame.Height | |
| 151 | + | mouseMode, mouseSGR := p.screen.MouseMode() | |
| 152 | + | altScreen := p.screen.AltScreenActive() | |
| 91 | 153 | scrollbackRows := 0 | |
| 92 | - | if tb.scrollback != nil { | |
| 93 | - | scrollbackRows = tb.scrollback.Len() | |
| 154 | + | if p.scrollback != nil { | |
| 155 | + | scrollbackRows = p.scrollback.Len() | |
| 94 | 156 | } | |
| 95 | - | tb.mu.Unlock() | |
| 157 | + | p.mu.Unlock() | |
| 96 | 158 | ||
| 97 | 159 | if mouseMode != 0 { | |
| 98 | 160 | if !mouseSGR || ev.Row == 0 || ev.Row > childRows { | |
| 99 | 161 | return | |
| 100 | 162 | } | |
| 101 | - | daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1)) | |
| 163 | + | if translated { | |
| 164 | + | daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw) | |
| 165 | + | } else { | |
| 166 | + | daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1)) | |
| 167 | + | } | |
| 102 | 168 | return | |
| 103 | 169 | } | |
| 104 | 170 | ||
| @@ -130,9 +196,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 130 | 196 | return | |
| 131 | 197 | } | |
| 132 | 198 | ||
| 133 | - | tb.mu.Lock() | |
| 134 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 135 | - | tb.mu.Unlock() | |
| 199 | + | p.mu.Lock() | |
| 200 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 201 | + | p.mu.Unlock() | |
| 136 | 202 | ||
| 137 | 203 | ac.copyMu.Lock() | |
| 138 | 204 | mode := scopy.NewMode(snap) | |
| @@ -165,6 +231,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 165 | 231 | } | |
| 166 | 232 | ||
| 167 | 233 | func sgrRowOffset(raw []byte, delta int) []byte { | |
| 234 | + | return sgrOffset(raw, 0, delta) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |
| 168 | 238 | if len(raw) < len("\x1b[<0;1;1M") { | |
| 169 | 239 | return raw | |
| 170 | 240 | } | |
| @@ -177,12 +247,17 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||
| 177 | 247 | if len(parts) != 3 { | |
| 178 | 248 | return raw | |
| 179 | 249 | } | |
| 250 | + | cx, err := strconv.Atoi(string(parts[1])) | |
| 251 | + | if err != nil { | |
| 252 | + | return raw | |
| 253 | + | } | |
| 180 | 254 | cy, err := strconv.Atoi(string(parts[2])) | |
| 181 | 255 | if err != nil { | |
| 182 | 256 | return raw | |
| 183 | 257 | } | |
| 184 | - | cy += delta | |
| 185 | - | if cy < 1 { | |
| 258 | + | cx += colDelta | |
| 259 | + | cy += rowDelta | |
| 260 | + | if cx < 1 || cy < 1 { | |
| 186 | 261 | return raw | |
| 187 | 262 | } | |
| 188 | 263 | ||
| @@ -190,7 +265,7 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||
| 190 | 265 | out = append(out, raw[:3]...) | |
| 191 | 266 | out = append(out, parts[0]...) | |
| 192 | 267 | out = append(out, ';') | |
| 193 | - | out = append(out, parts[1]...) | |
| 268 | + | out = strconv.AppendInt(out, int64(cx), 10) | |
| 194 | 269 | out = append(out, ';') | |
| 195 | 270 | out = strconv.AppendInt(out, int64(cy), 10) | |
| 196 | 271 | out = append(out, raw[end]) | |
| @@ -211,7 +286,13 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 211 | 286 | if tb == nil { | |
| 212 | 287 | return | |
| 213 | 288 | } | |
| 214 | - | if _, err := tb.pty.Write(data); err != nil { | |
| 289 | + | tb.mu.Lock() | |
| 290 | + | p := tb.focusedPane() | |
| 291 | + | tb.mu.Unlock() | |
| 292 | + | if p == nil { | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | if _, err := p.pty.Write(data); err != nil { | |
| 215 | 296 | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | |
| 216 | 297 | } | |
| 217 | 298 | } | |
| @@ -226,6 +307,14 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 226 | 307 | h.d.enterPalette(sess, h.ac) | |
| 227 | 308 | case keys.ActionJumpAttention: | |
| 228 | 309 | h.d.jumpAttention(sess, h.ac) | |
| 310 | + | case keys.ActionFocusPaneLeft: | |
| 311 | + | _ = h.d.focusDir(sess, h.ac, layout.Left) | |
| 312 | + | case keys.ActionFocusPaneRight: | |
| 313 | + | _ = h.d.focusDir(sess, h.ac, layout.Right) | |
| 314 | + | case keys.ActionFocusPaneUp: | |
| 315 | + | _ = h.d.focusDir(sess, h.ac, layout.Up) | |
| 316 | + | case keys.ActionFocusPaneDown: | |
| 317 | + | _ = h.d.focusDir(sess, h.ac, layout.Down) | |
| 229 | 318 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 230 | 319 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 231 | 320 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| @@ -31,6 +30,7 @@ import ( | |||||
| 31 | 30 | ||||
| 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | 31 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 33 | "github.com/bnema/vev/internal/usecase/keys" | 32 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 33 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 34 | "github.com/bnema/vev/internal/usecase/mouse" | 34 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 35 | ) | 35 | ) | ||
| 36 | 36 | ||||
| @@ -73,6 +73,23 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | if ac.copyModeActive() { | 75 | if ac.copyModeActive() { | ||
| 76 | + | if ev.Button == mouse.Left { | |||
| 77 | + | tb.mu.Lock() | |||
| 78 | + | p := tb.focusedPane() | |||
| 79 | + | var pl layout.Placement | |||
| 80 | + | var ok bool | |||
| 81 | + | multi := len(tb.panes) > 1 | |||
| 82 | + | if p != nil { | |||
| 83 | + | pl, ok = focusedPlacementLocked(tb) | |||
| 84 | + | } | |||
| 85 | + | tb.mu.Unlock() | |||
| 86 | + | if ok && multi { | |||
| 87 | + | if !pointInRect(ev.Col, ev.Row-1, pl.Content) { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 76 | switch ev.Button { | 93 | switch ev.Button { | ||
| 77 | case mouse.Left: | 94 | case mouse.Left: | ||
| 78 | d.copyMouse(sess, ac, ev) | 95 | d.copyMouse(sess, ac, ev) | ||
| @@ -85,20 +102,69 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 85 | } | 102 | } | ||
| 86 | 103 | ||||
| 87 | tb.mu.Lock() | 104 | tb.mu.Lock() | ||
| 88 | - | childRows := tb.screen.Frame.Height | 105 | + | contentRow := ev.Row - 1 |
| 89 | - | mouseMode, mouseSGR := tb.screen.MouseMode() | 106 | + | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) |
| 90 | - | altScreen := tb.screen.AltScreenActive() | 107 | + | if hit && pl.Collapsed && pointInRect(ev.Col, contentRow, pl.TitleBar) { |
| 108 | + | oldFocus := tb.tree.Focus | |||
| 109 | + | focusPlacementLocked(tb, pl.ID) | |||
| 110 | + | d.applyLayoutLocked(tb) | |||
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | if pl.ID != oldFocus { | |||
| 113 | + | d.exitCopyMode(ac) | |||
| 114 | + | d.refreshPaneTitle(sess, pl.ID) | |||
| 115 | + | } | |||
| 116 | + | d.paint(sess, ac, true) | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | var p *pane | |||
| 120 | + | translated := false | |||
| 121 | + | if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) { | |||
| 122 | + | oldFocus := tb.tree.Focus | |||
| 123 | + | focusPlacementLocked(tb, pl.ID) | |||
| 124 | + | d.applyLayoutLocked(tb) | |||
| 125 | + | p = tb.panes[pl.ID] | |||
| 126 | + | if len(tb.panes) == 1 { | |||
| 127 | + | p = tb.focusedPane() | |||
| 128 | + | } | |||
| 129 | + | tb.mu.Unlock() | |||
| 130 | + | if p == nil { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | if pl.ID != oldFocus { | |||
| 134 | + | d.exitCopyMode(ac) | |||
| 135 | + | d.refreshPaneTitle(sess, pl.ID) | |||
| 136 | + | d.paint(sess, ac, true) | |||
| 137 | + | } | |||
| 138 | + | if len(tb.panes) > 1 { | |||
| 139 | + | ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y) | |||
| 140 | + | translated = true | |||
| 141 | + | } | |||
| 142 | + | } else { | |||
| 143 | + | p = tb.focusedPane() | |||
| 144 | + | tb.mu.Unlock() | |||
| 145 | + | if p == nil { | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | p.mu.Lock() | |||
| 150 | + | childRows := p.screen.Frame.Height | |||
| 151 | + | mouseMode, mouseSGR := p.screen.MouseMode() | |||
| 152 | + | altScreen := p.screen.AltScreenActive() | |||
| 91 | scrollbackRows := 0 | 153 | scrollbackRows := 0 | ||
| 92 | - | if tb.scrollback != nil { | 154 | + | if p.scrollback != nil { |
| 93 | - | scrollbackRows = tb.scrollback.Len() | 155 | + | scrollbackRows = p.scrollback.Len() |
| 94 | } | 156 | } | ||
| 95 | - | tb.mu.Unlock() | 157 | + | p.mu.Unlock() |
| 96 | 158 | ||||
| 97 | if mouseMode != 0 { | 159 | if mouseMode != 0 { | ||
| 98 | if !mouseSGR || ev.Row == 0 || ev.Row > childRows { | 160 | if !mouseSGR || ev.Row == 0 || ev.Row > childRows { | ||
| 99 | return | 161 | return | ||
| 100 | } | 162 | } | ||
| 101 | - | daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1)) | 163 | + | if translated { |
| 164 | + | daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw) | |||
| 165 | + | } else { | |||
| 166 | + | daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1)) | |||
| 167 | + | } | |||
| 102 | return | 168 | return | ||
| 103 | } | 169 | } | ||
| 104 | 170 | ||||
| @@ -130,9 +196,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 130 | return | 196 | return | ||
| 131 | } | 197 | } | ||
| 132 | 198 | ||||
| 133 | - | tb.mu.Lock() | 199 | + | p.mu.Lock() |
| 134 | - | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | 200 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) |
| 135 | - | tb.mu.Unlock() | 201 | + | p.mu.Unlock() |
| 136 | 202 | ||||
| 137 | ac.copyMu.Lock() | 203 | ac.copyMu.Lock() | ||
| 138 | mode := scopy.NewMode(snap) | 204 | mode := scopy.NewMode(snap) | ||
| @@ -165,6 +231,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 165 | } | 231 | } | ||
| 166 | 232 | ||||
| 167 | func sgrRowOffset(raw []byte, delta int) []byte { | 233 | func sgrRowOffset(raw []byte, delta int) []byte { | ||
| 234 | + | return sgrOffset(raw, 0, delta) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 168 | if len(raw) < len("\x1b[<0;1;1M") { | 238 | if len(raw) < len("\x1b[<0;1;1M") { | ||
| 169 | return raw | 239 | return raw | ||
| 170 | } | 240 | } | ||
| @@ -177,12 +247,17 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||||
| 177 | if len(parts) != 3 { | 247 | if len(parts) != 3 { | ||
| 178 | return raw | 248 | return raw | ||
| 179 | } | 249 | } | ||
| 250 | + | cx, err := strconv.Atoi(string(parts[1])) | |||
| 251 | + | if err != nil { | |||
| 252 | + | return raw | |||
| 253 | + | } | |||
| 180 | cy, err := strconv.Atoi(string(parts[2])) | 254 | cy, err := strconv.Atoi(string(parts[2])) | ||
| 181 | if err != nil { | 255 | if err != nil { | ||
| 182 | return raw | 256 | return raw | ||
| 183 | } | 257 | } | ||
| 184 | - | cy += delta | 258 | + | cx += colDelta |
| 185 | - | if cy < 1 { | 259 | + | cy += rowDelta |
| 260 | + | if cx < 1 || cy < 1 { | |||
| 186 | return raw | 261 | return raw | ||
| 187 | } | 262 | } | ||
| 188 | 263 | ||||
| @@ -190,7 +265,7 @@ func sgrRowOffset(raw []byte, delta int) []byte { | |||||
| 190 | out = append(out, raw[:3]...) | 265 | out = append(out, raw[:3]...) | ||
| 191 | out = append(out, parts[0]...) | 266 | out = append(out, parts[0]...) | ||
| 192 | out = append(out, ';') | 267 | out = append(out, ';') | ||
| 193 | - | out = append(out, parts[1]...) | 268 | + | out = strconv.AppendInt(out, int64(cx), 10) |
| 194 | out = append(out, ';') | 269 | out = append(out, ';') | ||
| 195 | out = strconv.AppendInt(out, int64(cy), 10) | 270 | out = strconv.AppendInt(out, int64(cy), 10) | ||
| 196 | out = append(out, raw[end]) | 271 | out = append(out, raw[end]) | ||
| @@ -211,7 +286,13 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||||
| 211 | if tb == nil { | 286 | if tb == nil { | ||
| 212 | return | 287 | return | ||
| 213 | } | 288 | } | ||
| 214 | - | if _, err := tb.pty.Write(data); err != nil { | 289 | + | tb.mu.Lock() |
| 290 | + | p := tb.focusedPane() | |||
| 291 | + | tb.mu.Unlock() | |||
| 292 | + | if p == nil { | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | if _, err := p.pty.Write(data); err != nil { | |||
| 215 | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | 296 | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | ||
| 216 | } | 297 | } | ||
| 217 | } | 298 | } | ||
| @@ -226,6 +307,14 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 226 | h.d.enterPalette(sess, h.ac) | 307 | h.d.enterPalette(sess, h.ac) | ||
| 227 | case keys.ActionJumpAttention: | 308 | case keys.ActionJumpAttention: | ||
| 228 | h.d.jumpAttention(sess, h.ac) | 309 | h.d.jumpAttention(sess, h.ac) | ||
| 310 | + | case keys.ActionFocusPaneLeft: | |||
| 311 | + | _ = h.d.focusDir(sess, h.ac, layout.Left) | |||
| 312 | + | case keys.ActionFocusPaneRight: | |||
| 313 | + | _ = h.d.focusDir(sess, h.ac, layout.Right) | |||
| 314 | + | case keys.ActionFocusPaneUp: | |||
| 315 | + | _ = h.d.focusDir(sess, h.ac, layout.Up) | |||
| 316 | + | case keys.ActionFocusPaneDown: | |||
| 317 | + | _ = h.d.focusDir(sess, h.ac, layout.Down) | |||
| 229 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 318 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 230 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 319 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 231 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 320 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +48 -1
| @@ -11,7 +11,9 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 15 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| 17 | 19 | // --- test doubles ----------------------------------------------------------- | |
| @@ -406,7 +408,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 406 | 408 | ||
| 407 | 409 | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 408 | 410 | // first Motion (e.g. the child kept producing output). | |
| 409 | - | for i := 0; i < 5; i++ { | |
| 411 | + | for range 5 { | |
| 410 | 412 | sess.tabs[0].scrollback.Append(testRow("evicted")) | |
| 411 | 413 | } | |
| 412 | 414 | ||
| @@ -441,3 +443,48 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||
| 441 | 443 | default: | |
| 442 | 444 | } | |
| 443 | 445 | } | |
| 446 | + | ||
| 447 | + | func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |
| 448 | + | p1 := portsmocks.NewMockPTY(t) | |
| 449 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |
| 450 | + | p2 := portsmocks.NewMockPTY(t) | |
| 451 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |
| 452 | + | p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 453 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 454 | + | d.procComm = nil | |
| 455 | + | tb := sess.activeTab() | |
| 456 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |
| 457 | + | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 458 | + | tb.mu.Lock() | |
| 459 | + | tb.size = domain.Size{Cols: 41, Rows: 5} | |
| 460 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 461 | + | tb.tree.Focus = "pane-1" | |
| 462 | + | tb.panes["pane-2"] = p2pane | |
| 463 | + | tb.mu.Unlock() | |
| 464 | + | ||
| 465 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 466 | + | ||
| 467 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |
| 471 | + | p1 := portsmocks.NewMockPTY(t) | |
| 472 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |
| 473 | + | p2 := portsmocks.NewMockPTY(t) | |
| 474 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |
| 475 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 476 | + | d.procComm = nil | |
| 477 | + | tb := sess.activeTab() | |
| 478 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3}) | |
| 479 | + | tb.mu.Lock() | |
| 480 | + | tb.size = domain.Size{Cols: 20, Rows: 5} | |
| 481 | + | tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"} | |
| 482 | + | tb.tree.Focus = "pane-1" | |
| 483 | + | tb.panes["pane-2"] = p2pane | |
| 484 | + | tb.mu.Unlock() | |
| 485 | + | ||
| 486 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;6M")) | |
| 487 | + | ||
| 488 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 489 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| 490 | + | } | |
| @@ -11,7 +11,9 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 14 | scopy "github.com/bnema/vev/internal/usecase/copy" | 15 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 16 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| 17 | // --- test doubles ----------------------------------------------------------- | 19 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -406,7 +408,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||||
| 406 | 408 | ||||
| 407 | // Simulate 5 lines evicted into scrollback between the Press and the | 409 | // Simulate 5 lines evicted into scrollback between the Press and the | ||
| 408 | // first Motion (e.g. the child kept producing output). | 410 | // first Motion (e.g. the child kept producing output). | ||
| 409 | - | for i := 0; i < 5; i++ { | 411 | + | for range 5 { |
| 410 | sess.tabs[0].scrollback.Append(testRow("evicted")) | 412 | sess.tabs[0].scrollback.Append(testRow("evicted")) | ||
| 411 | } | 413 | } | ||
| 412 | 414 | ||||
| @@ -441,3 +443,48 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||||
| 441 | default: | 443 | default: | ||
| 442 | } | 444 | } | ||
| 443 | } | 445 | } | ||
| 446 | + | ||||
| 447 | + | func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |||
| 448 | + | p1 := portsmocks.NewMockPTY(t) | |||
| 449 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 450 | + | p2 := portsmocks.NewMockPTY(t) | |||
| 451 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 452 | + | p2.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 453 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 454 | + | d.procComm = nil | |||
| 455 | + | tb := sess.activeTab() | |||
| 456 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |||
| 457 | + | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 458 | + | tb.mu.Lock() | |||
| 459 | + | tb.size = domain.Size{Cols: 41, Rows: 5} | |||
| 460 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 461 | + | tb.tree.Focus = "pane-1" | |||
| 462 | + | tb.panes["pane-2"] = p2pane | |||
| 463 | + | tb.mu.Unlock() | |||
| 464 | + | ||||
| 465 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 466 | + | ||||
| 467 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |||
| 471 | + | p1 := portsmocks.NewMockPTY(t) | |||
| 472 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |||
| 473 | + | p2 := portsmocks.NewMockPTY(t) | |||
| 474 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |||
| 475 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |||
| 476 | + | d.procComm = nil | |||
| 477 | + | tb := sess.activeTab() | |||
| 478 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 3}) | |||
| 479 | + | tb.mu.Lock() | |||
| 480 | + | tb.size = domain.Size{Cols: 20, Rows: 5} | |||
| 481 | + | tb.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"} | |||
| 482 | + | tb.tree.Focus = "pane-1" | |||
| 483 | + | tb.panes["pane-2"] = p2pane | |||
| 484 | + | tb.mu.Unlock() | |||
| 485 | + | ||||
| 486 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;6M")) | |||
| 487 | + | ||||
| 488 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 489 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |||
| 490 | + | } | |||
A internal/usecase/daemon/mouse_panes.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func focusedPlacementLocked(tb *tab) (layout.Placement, bool) { | |
| 10 | + | if tb == nil || tb.tree == nil { | |
| 11 | + | return layout.Placement{}, false | |
| 12 | + | } | |
| 13 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 14 | + | if !ok { | |
| 15 | + | return layout.Placement{}, false | |
| 16 | + | } | |
| 17 | + | for _, pl := range placements { | |
| 18 | + | if pl.ID == tb.tree.Focus { | |
| 19 | + | return pl, true | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | return layout.Placement{}, false | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) { | |
| 26 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 27 | + | return layout.Placement{}, false | |
| 28 | + | } | |
| 29 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 30 | + | if !ok { | |
| 31 | + | return layout.Placement{}, false | |
| 32 | + | } | |
| 33 | + | for _, pl := range placements { | |
| 34 | + | if pointInRect(col, row, pl.TitleBar) || pointInRect(col, row, pl.Content) { | |
| 35 | + | return pl, true | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return layout.Placement{}, false | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func solvedPlacementsLocked(tb *tab) ([]layout.Placement, bool) { | |
| 42 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 43 | + | return nil, false | |
| 44 | + | } | |
| 45 | + | return layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func pointInRect(col, row int, r domain.Rect) bool { | |
| 49 | + | return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func focusPlacementLocked(tb *tab, id layout.PaneID) { | |
| 53 | + | if tb == nil || tb.tree == nil { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | tb.tree.Focus = id | |
| 57 | + | setExpandedLocked(tb.tree.Root, id) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |
| 61 | + | if n == nil { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | if n.Kind == layout.Stack { | |
| 65 | + | for _, child := range n.Children { | |
| 66 | + | if child.Kind == layout.Leaf && child.Leaf == id { | |
| 67 | + | n.Expanded = id | |
| 68 | + | return true | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | for _, child := range n.Children { | |
| 73 | + | if setExpandedLocked(child, id) { | |
| 74 | + | return true | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | |
| 81 | + | ev.Col -= colOffset | |
| 82 | + | ev.Row -= contentYOffset | |
| 83 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | |
| 84 | + | return ev | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (d *Daemon) exitCopyMode(ac *attachedClient) { | |
| 88 | + | if ac == nil { | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | ac.copyMu.Lock() | |
| 92 | + | ac.copyMode = nil | |
| 93 | + | ac.copyPressRowValid = false | |
| 94 | + | ac.copyDragging = false | |
| 95 | + | ac.normalMousePressValid = false | |
| 96 | + | ac.copyMu.Unlock() | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func focusedPlacementLocked(tb *tab) (layout.Placement, bool) { | |||
| 10 | + | if tb == nil || tb.tree == nil { | |||
| 11 | + | return layout.Placement{}, false | |||
| 12 | + | } | |||
| 13 | + | placements, ok := solvedPlacementsLocked(tb) | |||
| 14 | + | if !ok { | |||
| 15 | + | return layout.Placement{}, false | |||
| 16 | + | } | |||
| 17 | + | for _, pl := range placements { | |||
| 18 | + | if pl.ID == tb.tree.Focus { | |||
| 19 | + | return pl, true | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | return layout.Placement{}, false | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func hitTestPlacementLocked(tb *tab, col, row int) (layout.Placement, bool) { | |||
| 26 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |||
| 27 | + | return layout.Placement{}, false | |||
| 28 | + | } | |||
| 29 | + | placements, ok := solvedPlacementsLocked(tb) | |||
| 30 | + | if !ok { | |||
| 31 | + | return layout.Placement{}, false | |||
| 32 | + | } | |||
| 33 | + | for _, pl := range placements { | |||
| 34 | + | if pointInRect(col, row, pl.TitleBar) || pointInRect(col, row, pl.Content) { | |||
| 35 | + | return pl, true | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | return layout.Placement{}, false | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func solvedPlacementsLocked(tb *tab) ([]layout.Placement, bool) { | |||
| 42 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |||
| 43 | + | return nil, false | |||
| 44 | + | } | |||
| 45 | + | return layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func pointInRect(col, row int, r domain.Rect) bool { | |||
| 49 | + | return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func focusPlacementLocked(tb *tab, id layout.PaneID) { | |||
| 53 | + | if tb == nil || tb.tree == nil { | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | tb.tree.Focus = id | |||
| 57 | + | setExpandedLocked(tb.tree.Root, id) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||
| 61 | + | if n == nil { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | if n.Kind == layout.Stack { | |||
| 65 | + | for _, child := range n.Children { | |||
| 66 | + | if child.Kind == layout.Leaf && child.Leaf == id { | |||
| 67 | + | n.Expanded = id | |||
| 68 | + | return true | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | for _, child := range n.Children { | |||
| 73 | + | if setExpandedLocked(child, id) { | |||
| 74 | + | return true | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | |||
| 81 | + | ev.Col -= colOffset | |||
| 82 | + | ev.Row -= contentYOffset | |||
| 83 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | |||
| 84 | + | return ev | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||
| 88 | + | if ac == nil { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | ac.copyMu.Lock() | |||
| 92 | + | ac.copyMode = nil | |||
| 93 | + | ac.copyPressRowValid = false | |||
| 94 | + | ac.copyDragging = false | |||
| 95 | + | ac.normalMousePressValid = false | |||
| 96 | + | ac.copyMu.Unlock() | |||
| 97 | + | } | |||
M internal/usecase/daemon/palette.go +45 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/vev/internal/domain" | |
| 5 | 5 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/palette" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 8 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -159,6 +160,50 @@ func (e paletteExec) CloseTab() error { | |||
| 159 | 160 | return nil | |
| 160 | 161 | } | |
| 161 | 162 | ||
| 163 | + | func (e paletteExec) SplitRight() error { | |
| 164 | + | return e.d.splitPane(e.sess, e.ac, layout.Right) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (e paletteExec) SplitLeft() error { | |
| 168 | + | return e.d.splitPane(e.sess, e.ac, layout.Left) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (e paletteExec) SplitUp() error { | |
| 172 | + | return e.d.splitPane(e.sess, e.ac, layout.Up) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (e paletteExec) SplitDown() error { | |
| 176 | + | return e.d.splitPane(e.sess, e.ac, layout.Down) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (e paletteExec) StackPane() error { | |
| 180 | + | return e.d.stackPane(e.sess, e.ac) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (e paletteExec) ToggleStack() error { | |
| 184 | + | return e.d.toggleStack(e.sess, e.ac) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (e paletteExec) ClosePane() error { | |
| 188 | + | return e.d.closeFocusedPane(e.sess, e.ac) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (e paletteExec) FocusPaneLeft() error { | |
| 192 | + | return e.d.focusDir(e.sess, e.ac, layout.Left) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func (e paletteExec) FocusPaneRight() error { | |
| 196 | + | return e.d.focusDir(e.sess, e.ac, layout.Right) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (e paletteExec) FocusPaneUp() error { | |
| 200 | + | return e.d.focusDir(e.sess, e.ac, layout.Up) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (e paletteExec) FocusPaneDown() error { | |
| 204 | + | return e.d.focusDir(e.sess, e.ac, layout.Down) | |
| 205 | + | } | |
| 206 | + | ||
| 162 | 207 | func (e paletteExec) NextTab() error { | |
| 163 | 208 | e.sess.switchRelative(1) | |
| 164 | 209 | e.d.paint(e.sess, e.ac, true) | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "github.com/bnema/vev/internal/domain" | 4 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/command" | 5 | "github.com/bnema/vev/internal/usecase/command" | ||
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | "github.com/bnema/vev/internal/usecase/palette" | 7 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 7 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 8 | "github.com/bnema/vev/pkg/renderer" | 9 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -159,6 +160,50 @@ func (e paletteExec) CloseTab() error { | |||||
| 159 | return nil | 160 | return nil | ||
| 160 | } | 161 | } | ||
| 161 | 162 | ||||
| 163 | + | func (e paletteExec) SplitRight() error { | |||
| 164 | + | return e.d.splitPane(e.sess, e.ac, layout.Right) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (e paletteExec) SplitLeft() error { | |||
| 168 | + | return e.d.splitPane(e.sess, e.ac, layout.Left) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (e paletteExec) SplitUp() error { | |||
| 172 | + | return e.d.splitPane(e.sess, e.ac, layout.Up) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (e paletteExec) SplitDown() error { | |||
| 176 | + | return e.d.splitPane(e.sess, e.ac, layout.Down) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (e paletteExec) StackPane() error { | |||
| 180 | + | return e.d.stackPane(e.sess, e.ac) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func (e paletteExec) ToggleStack() error { | |||
| 184 | + | return e.d.toggleStack(e.sess, e.ac) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (e paletteExec) ClosePane() error { | |||
| 188 | + | return e.d.closeFocusedPane(e.sess, e.ac) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func (e paletteExec) FocusPaneLeft() error { | |||
| 192 | + | return e.d.focusDir(e.sess, e.ac, layout.Left) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (e paletteExec) FocusPaneRight() error { | |||
| 196 | + | return e.d.focusDir(e.sess, e.ac, layout.Right) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (e paletteExec) FocusPaneUp() error { | |||
| 200 | + | return e.d.focusDir(e.sess, e.ac, layout.Up) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (e paletteExec) FocusPaneDown() error { | |||
| 204 | + | return e.d.focusDir(e.sess, e.ac, layout.Down) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 162 | func (e paletteExec) NextTab() error { | 207 | func (e paletteExec) NextTab() error { | ||
| 163 | e.sess.switchRelative(1) | 208 | e.sess.switchRelative(1) | ||
| 164 | e.d.paint(e.sess, e.ac, true) | 209 | e.d.paint(e.sess, e.ac, true) | ||
M internal/usecase/daemon/palette_test.go +13 -2
| @@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 112 | 112 | for i, cmd := range commands { | |
| 113 | 113 | codes[i] = cmd.Code | |
| 114 | 114 | } | |
| 115 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "PVT", "VIS", "RNS", "DET"}, codes) | |
| 115 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 119 | 119 | d := &Daemon{} | |
| 120 | - | codes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 120 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 121 | 121 | ||
| 122 | 122 | var wg sync.WaitGroup | |
| 123 | 123 | for range 50 { | |
| @@ -307,6 +307,17 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 307 | 307 | ac.copyMu.Lock() | |
| 308 | 308 | ac.copyMode = nil | |
| 309 | 309 | ac.copyMu.Unlock() | |
| 310 | + | require.NoError(t, exec.SplitRight()) | |
| 311 | + | require.NoError(t, exec.SplitLeft()) | |
| 312 | + | require.NoError(t, exec.SplitUp()) | |
| 313 | + | require.NoError(t, exec.SplitDown()) | |
| 314 | + | require.NoError(t, exec.StackPane()) | |
| 315 | + | require.NoError(t, exec.ToggleStack()) | |
| 316 | + | require.NoError(t, exec.ClosePane()) | |
| 317 | + | require.NoError(t, exec.FocusPaneLeft()) | |
| 318 | + | require.NoError(t, exec.FocusPaneRight()) | |
| 319 | + | require.NoError(t, exec.FocusPaneUp()) | |
| 320 | + | require.NoError(t, exec.FocusPaneDown()) | |
| 310 | 321 | require.NoError(t, exec.CloseTab()) | |
| 311 | 322 | require.Len(t, sess.tabs, 2) | |
| 312 | 323 | // Drain paints generated by methods above. | |
| @@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 112 | for i, cmd := range commands { | 112 | for i, cmd := range commands { | ||
| 113 | codes[i] = cmd.Code | 113 | codes[i] = cmd.Code | ||
| 114 | } | 114 | } | ||
| 115 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "PVT", "VIS", "RNS", "DET"}, codes) | 115 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "VIS", "RNS", "DET"}, codes) |
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| 119 | d := &Daemon{} | 119 | d := &Daemon{} | ||
| 120 | - | codes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | 120 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} |
| 121 | 121 | ||||
| 122 | var wg sync.WaitGroup | 122 | var wg sync.WaitGroup | ||
| 123 | for range 50 { | 123 | for range 50 { | ||
| @@ -307,6 +307,17 @@ func TestPaletteExecMethods(t *testing.T) { | |||||
| 307 | ac.copyMu.Lock() | 307 | ac.copyMu.Lock() | ||
| 308 | ac.copyMode = nil | 308 | ac.copyMode = nil | ||
| 309 | ac.copyMu.Unlock() | 309 | ac.copyMu.Unlock() | ||
| 310 | + | require.NoError(t, exec.SplitRight()) | |||
| 311 | + | require.NoError(t, exec.SplitLeft()) | |||
| 312 | + | require.NoError(t, exec.SplitUp()) | |||
| 313 | + | require.NoError(t, exec.SplitDown()) | |||
| 314 | + | require.NoError(t, exec.StackPane()) | |||
| 315 | + | require.NoError(t, exec.ToggleStack()) | |||
| 316 | + | require.NoError(t, exec.ClosePane()) | |||
| 317 | + | require.NoError(t, exec.FocusPaneLeft()) | |||
| 318 | + | require.NoError(t, exec.FocusPaneRight()) | |||
| 319 | + | require.NoError(t, exec.FocusPaneUp()) | |||
| 320 | + | require.NoError(t, exec.FocusPaneDown()) | |||
| 310 | require.NoError(t, exec.CloseTab()) | 321 | require.NoError(t, exec.CloseTab()) | ||
| 311 | require.Len(t, sess.tabs, 2) | 322 | require.Len(t, sess.tabs, 2) | ||
| 312 | // Drain paints generated by methods above. | 323 | // Drain paints generated by methods above. | ||
A internal/usecase/daemon/pane.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // pane owns one PTY, its terminal screen state, and render scheduling channels. | |
| 15 | + | // Lock order when multiple locks are held is: | |
| 16 | + | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | + | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 18 | + | type pane struct { | |
| 19 | + | id layout.PaneID | |
| 20 | + | pty ports.PTY | |
| 21 | + | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | |
| 22 | + | screen *vt.Screen | |
| 23 | + | scrollback *scopy.Scrollback | |
| 24 | + | dirty chan struct{} | |
| 25 | + | flush chan struct{} | |
| 26 | + | syncGen uint64 | |
| 27 | + | rect domain.Rect | |
| 28 | + | title string | |
| 29 | + | ctx context.Context | |
| 30 | + | cancel context.CancelFunc | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |
| 34 | + | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 35 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 36 | + | screen.OnLineEvicted = sb.Append | |
| 37 | + | return &pane{ | |
| 38 | + | id: id, | |
| 39 | + | pty: pty, | |
| 40 | + | screen: screen, | |
| 41 | + | scrollback: sb, | |
| 42 | + | dirty: make(chan struct{}, 1), | |
| 43 | + | flush: make(chan struct{}, 1), | |
| 44 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func paneDone(p *pane) <-chan struct{} { | |
| 49 | + | if p.ctx == nil { | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | return p.ctx.Done() | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | "github.com/bnema/vev/pkg/vt" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // pane owns one PTY, its terminal screen state, and render scheduling channels. | |||
| 15 | + | // Lock order when multiple locks are held is: | |||
| 16 | + | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |||
| 17 | + | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |||
| 18 | + | type pane struct { | |||
| 19 | + | id layout.PaneID | |||
| 20 | + | pty ports.PTY | |||
| 21 | + | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | |||
| 22 | + | screen *vt.Screen | |||
| 23 | + | scrollback *scopy.Scrollback | |||
| 24 | + | dirty chan struct{} | |||
| 25 | + | flush chan struct{} | |||
| 26 | + | syncGen uint64 | |||
| 27 | + | rect domain.Rect | |||
| 28 | + | title string | |||
| 29 | + | ctx context.Context | |||
| 30 | + | cancel context.CancelFunc | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |||
| 34 | + | sb := scopy.NewScrollback(defaultScrollbackRows) | |||
| 35 | + | screen := vt.NewScreen(sz.Cols, sz.Rows) | |||
| 36 | + | screen.OnLineEvicted = sb.Append | |||
| 37 | + | return &pane{ | |||
| 38 | + | id: id, | |||
| 39 | + | pty: pty, | |||
| 40 | + | screen: screen, | |||
| 41 | + | scrollback: sb, | |||
| 42 | + | dirty: make(chan struct{}, 1), | |||
| 43 | + | flush: make(chan struct{}, 1), | |||
| 44 | + | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func paneDone(p *pane) <-chan struct{} { | |||
| 49 | + | if p.ctx == nil { | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | return p.ctx.Done() | |||
| 53 | + | } | |||
A internal/usecase/daemon/pane_actions.go +351 -0
| @@ -0,0 +1,351 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 12 | + | if d.ptys == nil { | |
| 13 | + | if ac != nil { | |
| 14 | + | d.paint(sess, ac, true) | |
| 15 | + | } | |
| 16 | + | return nil | |
| 17 | + | } | |
| 18 | + | tb := sess.activeTab() | |
| 19 | + | if tb == nil { | |
| 20 | + | return layout.ErrNotFound | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | sess.mu.Lock() | |
| 24 | + | name, cwd := sess.name, sess.cwd | |
| 25 | + | sess.mu.Unlock() | |
| 26 | + | ||
| 27 | + | tb.mu.Lock() | |
| 28 | + | if tb.tree == nil || tb.tree.Root == nil { | |
| 29 | + | tb.mu.Unlock() | |
| 30 | + | return layout.ErrNotFound | |
| 31 | + | } | |
| 32 | + | oldFocus := tb.tree.Focus | |
| 33 | + | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 34 | + | after := dir == layout.Right || dir == layout.Down | |
| 35 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 36 | + | if err := tb.tree.Split(oldFocus, dir, after, newID, area); err != nil { | |
| 37 | + | tb.mu.Unlock() | |
| 38 | + | return err | |
| 39 | + | } | |
| 40 | + | tb.nextPaneID++ | |
| 41 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 42 | + | if !ok { | |
| 43 | + | _ = tb.tree.Close(newID) | |
| 44 | + | tb.tree.Focus = oldFocus | |
| 45 | + | tb.nextPaneID-- | |
| 46 | + | tb.mu.Unlock() | |
| 47 | + | return layout.ErrTooSmall | |
| 48 | + | } | |
| 49 | + | newRect := placementContent(placements, newID) | |
| 50 | + | tb.mu.Unlock() | |
| 51 | + | ||
| 52 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |
| 53 | + | if err != nil { | |
| 54 | + | tb.mu.Lock() | |
| 55 | + | _ = tb.tree.Close(newID) | |
| 56 | + | tb.tree.Focus = oldFocus | |
| 57 | + | tb.nextPaneID-- | |
| 58 | + | tb.mu.Unlock() | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pctx, cancel := context.WithCancel(tb.ctx) | |
| 63 | + | p := newPane(newID, pty, rectSize(newRect)) | |
| 64 | + | p.ctx, p.cancel = pctx, cancel | |
| 65 | + | p.rect = newRect | |
| 66 | + | ||
| 67 | + | tb.mu.Lock() | |
| 68 | + | if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) { | |
| 69 | + | tb.mu.Unlock() | |
| 70 | + | cancel() | |
| 71 | + | _ = pty.Close() | |
| 72 | + | return layout.ErrNotFound | |
| 73 | + | } | |
| 74 | + | tb.panes[newID] = p | |
| 75 | + | tb.tree.Focus = newID | |
| 76 | + | d.applyLayoutLocked(tb) | |
| 77 | + | tb.mu.Unlock() | |
| 78 | + | ||
| 79 | + | d.startPaneGoroutines(sess, tb, p) | |
| 80 | + | if ac != nil { | |
| 81 | + | d.paint(sess, ac, true) | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (d *Daemon) applyLayoutLocked(tb *tab) { | |
| 87 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 91 | + | if !ok { | |
| 92 | + | for _, p := range tb.panes { | |
| 93 | + | d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 94 | + | } | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | for _, pl := range placements { | |
| 98 | + | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | if p := tb.panes[pl.ID]; p != nil { | |
| 102 | + | d.resizePane(p, pl.Content) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (d *Daemon) resizePane(p *pane, r domain.Rect) { | |
| 108 | + | if p == nil { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | p.mu.Lock() | |
| 112 | + | old := p.rect | |
| 113 | + | p.mu.Unlock() | |
| 114 | + | if old == r { | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | sz := rectSize(r) | |
| 118 | + | if err := p.pty.Resize(sz); err != nil { | |
| 119 | + | d.log.Warn("pty resize failed", "err", err) | |
| 120 | + | } | |
| 121 | + | p.mu.Lock() | |
| 122 | + | p.screen.Resize(sz.Cols, sz.Rows) | |
| 123 | + | p.rect = r | |
| 124 | + | p.mu.Unlock() | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | |
| 128 | + | for _, pl := range placements { | |
| 129 | + | if pl.ID == id { | |
| 130 | + | return pl.Content | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | return domain.Rect{} | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |
| 137 | + | ||
| 138 | + | func layoutContains(n *layout.Node, id layout.PaneID) bool { | |
| 139 | + | if n == nil { | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | if n.Kind == layout.Leaf { | |
| 143 | + | return n.Leaf == id | |
| 144 | + | } | |
| 145 | + | for _, child := range n.Children { | |
| 146 | + | if layoutContains(child, id) { | |
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |
| 154 | + | if d.ptys == nil { | |
| 155 | + | if ac != nil { | |
| 156 | + | d.paint(sess, ac, true) | |
| 157 | + | } | |
| 158 | + | return nil | |
| 159 | + | } | |
| 160 | + | tb := sess.activeTab() | |
| 161 | + | if tb == nil { | |
| 162 | + | return layout.ErrNotFound | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | sess.mu.Lock() | |
| 166 | + | name, cwd := sess.name, sess.cwd | |
| 167 | + | sess.mu.Unlock() | |
| 168 | + | ||
| 169 | + | tb.mu.Lock() | |
| 170 | + | if tb.tree == nil || tb.tree.Root == nil { | |
| 171 | + | tb.mu.Unlock() | |
| 172 | + | return layout.ErrNotFound | |
| 173 | + | } | |
| 174 | + | oldFocus := tb.tree.Focus | |
| 175 | + | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |
| 176 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |
| 177 | + | if err := tb.tree.StackNew(oldFocus, newID, area); err != nil { | |
| 178 | + | tb.mu.Unlock() | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | tb.nextPaneID++ | |
| 182 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |
| 183 | + | if !ok { | |
| 184 | + | _ = tb.tree.Close(newID) | |
| 185 | + | tb.tree.Focus = oldFocus | |
| 186 | + | tb.nextPaneID-- | |
| 187 | + | tb.mu.Unlock() | |
| 188 | + | return layout.ErrTooSmall | |
| 189 | + | } | |
| 190 | + | newRect := placementContent(placements, newID) | |
| 191 | + | tb.mu.Unlock() | |
| 192 | + | ||
| 193 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |
| 194 | + | if err != nil { | |
| 195 | + | tb.mu.Lock() | |
| 196 | + | _ = tb.tree.Close(newID) | |
| 197 | + | tb.tree.Focus = oldFocus | |
| 198 | + | tb.nextPaneID-- | |
| 199 | + | tb.mu.Unlock() | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | pctx, cancel := context.WithCancel(tb.ctx) | |
| 204 | + | p := newPane(newID, pty, rectSize(newRect)) | |
| 205 | + | p.ctx, p.cancel = pctx, cancel | |
| 206 | + | p.rect = newRect | |
| 207 | + | ||
| 208 | + | tb.mu.Lock() | |
| 209 | + | if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) { | |
| 210 | + | tb.mu.Unlock() | |
| 211 | + | cancel() | |
| 212 | + | _ = pty.Close() | |
| 213 | + | return layout.ErrNotFound | |
| 214 | + | } | |
| 215 | + | tb.panes[newID] = p | |
| 216 | + | tb.tree.Focus = newID | |
| 217 | + | d.applyLayoutLocked(tb) | |
| 218 | + | tb.mu.Unlock() | |
| 219 | + | ||
| 220 | + | d.startPaneGoroutines(sess, tb, p) | |
| 221 | + | if ac != nil { | |
| 222 | + | d.paint(sess, ac, true) | |
| 223 | + | } | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |
| 228 | + | if d.ptys == nil { | |
| 229 | + | if ac != nil { | |
| 230 | + | d.paint(sess, ac, true) | |
| 231 | + | } | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | tb := sess.activeTab() | |
| 235 | + | if tb == nil { | |
| 236 | + | return layout.ErrNotFound | |
| 237 | + | } | |
| 238 | + | tb.mu.Lock() | |
| 239 | + | err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 240 | + | if err == nil { | |
| 241 | + | d.applyLayoutLocked(tb) | |
| 242 | + | } | |
| 243 | + | tb.mu.Unlock() | |
| 244 | + | if err == nil && ac != nil { | |
| 245 | + | d.paint(sess, ac, true) | |
| 246 | + | } | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |
| 251 | + | if d.ptys == nil { | |
| 252 | + | if ac != nil { | |
| 253 | + | d.paint(sess, ac, true) | |
| 254 | + | } | |
| 255 | + | return nil | |
| 256 | + | } | |
| 257 | + | tb := sess.activeTab() | |
| 258 | + | if tb == nil { | |
| 259 | + | return layout.ErrNotFound | |
| 260 | + | } | |
| 261 | + | tb.mu.Lock() | |
| 262 | + | if tb.tree == nil { | |
| 263 | + | tb.mu.Unlock() | |
| 264 | + | return layout.ErrNotFound | |
| 265 | + | } | |
| 266 | + | id := tb.tree.Focus | |
| 267 | + | tb.mu.Unlock() | |
| 268 | + | return d.closePane(sess, tb, id, ac, true) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) { | |
| 272 | + | if p == nil { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | _ = d.closePane(sess, tb, p.id, nil, true) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error { | |
| 279 | + | if tb == nil { | |
| 280 | + | return layout.ErrNotFound | |
| 281 | + | } | |
| 282 | + | tb.mu.Lock() | |
| 283 | + | p := tb.panes[id] | |
| 284 | + | if p == nil || tb.tree == nil || !layoutContains(tb.tree.Root, id) { | |
| 285 | + | tb.mu.Unlock() | |
| 286 | + | return nil | |
| 287 | + | } | |
| 288 | + | if len(tb.panes) <= 1 { | |
| 289 | + | tb.mu.Unlock() | |
| 290 | + | d.closeTab(sess, tb, repaint) | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | if err := tb.tree.Close(id); err != nil { | |
| 294 | + | tb.mu.Unlock() | |
| 295 | + | return err | |
| 296 | + | } | |
| 297 | + | delete(tb.panes, id) | |
| 298 | + | d.applyLayoutLocked(tb) | |
| 299 | + | tb.mu.Unlock() | |
| 300 | + | ||
| 301 | + | if p.cancel != nil { | |
| 302 | + | p.cancel() | |
| 303 | + | } | |
| 304 | + | _ = p.pty.Close() | |
| 305 | + | if repaint { | |
| 306 | + | if ac == nil { | |
| 307 | + | sess.mu.Lock() | |
| 308 | + | ac = sess.client | |
| 309 | + | sess.mu.Unlock() | |
| 310 | + | } | |
| 311 | + | if ac != nil { | |
| 312 | + | d.paint(sess, ac, true) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | return nil | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | |
| 319 | + | tb := sess.activeTab() | |
| 320 | + | if tb == nil { | |
| 321 | + | return layout.ErrNotFound | |
| 322 | + | } | |
| 323 | + | tb.mu.Lock() | |
| 324 | + | if tb.tree == nil { | |
| 325 | + | tb.mu.Unlock() | |
| 326 | + | return layout.ErrNotFound | |
| 327 | + | } | |
| 328 | + | oldFocus := tb.tree.Focus | |
| 329 | + | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 330 | + | newFocus := tb.tree.Focus | |
| 331 | + | if err == nil { | |
| 332 | + | d.applyLayoutLocked(tb) | |
| 333 | + | } | |
| 334 | + | tb.mu.Unlock() | |
| 335 | + | if err == nil && ac != nil { | |
| 336 | + | if newFocus != oldFocus { | |
| 337 | + | d.exitCopyMode(ac) | |
| 338 | + | tb.mu.Lock() | |
| 339 | + | pl, ok := focusedPlacementLocked(tb) | |
| 340 | + | tb.mu.Unlock() | |
| 341 | + | if ok && pl.TitleBar.Height > 0 { | |
| 342 | + | d.refreshPaneTitle(sess, newFocus) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | d.paint(sess, ac, true) | |
| 346 | + | } | |
| 347 | + | if err == layout.ErrNoPane { | |
| 348 | + | return nil | |
| 349 | + | } | |
| 350 | + | return err | |
| 351 | + | } | |
| @@ -0,0 +1,351 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Direction) error { | |||
| 12 | + | if d.ptys == nil { | |||
| 13 | + | if ac != nil { | |||
| 14 | + | d.paint(sess, ac, true) | |||
| 15 | + | } | |||
| 16 | + | return nil | |||
| 17 | + | } | |||
| 18 | + | tb := sess.activeTab() | |||
| 19 | + | if tb == nil { | |||
| 20 | + | return layout.ErrNotFound | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | sess.mu.Lock() | |||
| 24 | + | name, cwd := sess.name, sess.cwd | |||
| 25 | + | sess.mu.Unlock() | |||
| 26 | + | ||||
| 27 | + | tb.mu.Lock() | |||
| 28 | + | if tb.tree == nil || tb.tree.Root == nil { | |||
| 29 | + | tb.mu.Unlock() | |||
| 30 | + | return layout.ErrNotFound | |||
| 31 | + | } | |||
| 32 | + | oldFocus := tb.tree.Focus | |||
| 33 | + | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |||
| 34 | + | after := dir == layout.Right || dir == layout.Down | |||
| 35 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |||
| 36 | + | if err := tb.tree.Split(oldFocus, dir, after, newID, area); err != nil { | |||
| 37 | + | tb.mu.Unlock() | |||
| 38 | + | return err | |||
| 39 | + | } | |||
| 40 | + | tb.nextPaneID++ | |||
| 41 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |||
| 42 | + | if !ok { | |||
| 43 | + | _ = tb.tree.Close(newID) | |||
| 44 | + | tb.tree.Focus = oldFocus | |||
| 45 | + | tb.nextPaneID-- | |||
| 46 | + | tb.mu.Unlock() | |||
| 47 | + | return layout.ErrTooSmall | |||
| 48 | + | } | |||
| 49 | + | newRect := placementContent(placements, newID) | |||
| 50 | + | tb.mu.Unlock() | |||
| 51 | + | ||||
| 52 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |||
| 53 | + | if err != nil { | |||
| 54 | + | tb.mu.Lock() | |||
| 55 | + | _ = tb.tree.Close(newID) | |||
| 56 | + | tb.tree.Focus = oldFocus | |||
| 57 | + | tb.nextPaneID-- | |||
| 58 | + | tb.mu.Unlock() | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | pctx, cancel := context.WithCancel(tb.ctx) | |||
| 63 | + | p := newPane(newID, pty, rectSize(newRect)) | |||
| 64 | + | p.ctx, p.cancel = pctx, cancel | |||
| 65 | + | p.rect = newRect | |||
| 66 | + | ||||
| 67 | + | tb.mu.Lock() | |||
| 68 | + | if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) { | |||
| 69 | + | tb.mu.Unlock() | |||
| 70 | + | cancel() | |||
| 71 | + | _ = pty.Close() | |||
| 72 | + | return layout.ErrNotFound | |||
| 73 | + | } | |||
| 74 | + | tb.panes[newID] = p | |||
| 75 | + | tb.tree.Focus = newID | |||
| 76 | + | d.applyLayoutLocked(tb) | |||
| 77 | + | tb.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | d.startPaneGoroutines(sess, tb, p) | |||
| 80 | + | if ac != nil { | |||
| 81 | + | d.paint(sess, ac, true) | |||
| 82 | + | } | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 87 | + | if tb == nil || tb.tree == nil || tb.tree.Root == nil || !tb.size.Valid() { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 91 | + | if !ok { | |||
| 92 | + | for _, p := range tb.panes { | |||
| 93 | + | d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 94 | + | } | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | for _, pl := range placements { | |||
| 98 | + | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | if p := tb.panes[pl.ID]; p != nil { | |||
| 102 | + | d.resizePane(p, pl.Content) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (d *Daemon) resizePane(p *pane, r domain.Rect) { | |||
| 108 | + | if p == nil { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | p.mu.Lock() | |||
| 112 | + | old := p.rect | |||
| 113 | + | p.mu.Unlock() | |||
| 114 | + | if old == r { | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | sz := rectSize(r) | |||
| 118 | + | if err := p.pty.Resize(sz); err != nil { | |||
| 119 | + | d.log.Warn("pty resize failed", "err", err) | |||
| 120 | + | } | |||
| 121 | + | p.mu.Lock() | |||
| 122 | + | p.screen.Resize(sz.Cols, sz.Rows) | |||
| 123 | + | p.rect = r | |||
| 124 | + | p.mu.Unlock() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func placementContent(placements []layout.Placement, id layout.PaneID) domain.Rect { | |||
| 128 | + | for _, pl := range placements { | |||
| 129 | + | if pl.ID == id { | |||
| 130 | + | return pl.Content | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | return domain.Rect{} | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} } | |||
| 137 | + | ||||
| 138 | + | func layoutContains(n *layout.Node, id layout.PaneID) bool { | |||
| 139 | + | if n == nil { | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | if n.Kind == layout.Leaf { | |||
| 143 | + | return n.Leaf == id | |||
| 144 | + | } | |||
| 145 | + | for _, child := range n.Children { | |||
| 146 | + | if layoutContains(child, id) { | |||
| 147 | + | return true | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func (d *Daemon) stackPane(sess *session, ac *attachedClient) error { | |||
| 154 | + | if d.ptys == nil { | |||
| 155 | + | if ac != nil { | |||
| 156 | + | d.paint(sess, ac, true) | |||
| 157 | + | } | |||
| 158 | + | return nil | |||
| 159 | + | } | |||
| 160 | + | tb := sess.activeTab() | |||
| 161 | + | if tb == nil { | |||
| 162 | + | return layout.ErrNotFound | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | sess.mu.Lock() | |||
| 166 | + | name, cwd := sess.name, sess.cwd | |||
| 167 | + | sess.mu.Unlock() | |||
| 168 | + | ||||
| 169 | + | tb.mu.Lock() | |||
| 170 | + | if tb.tree == nil || tb.tree.Root == nil { | |||
| 171 | + | tb.mu.Unlock() | |||
| 172 | + | return layout.ErrNotFound | |||
| 173 | + | } | |||
| 174 | + | oldFocus := tb.tree.Focus | |||
| 175 | + | newID := layout.PaneID(fmt.Sprintf("pane-%d", tb.nextPaneID)) | |||
| 176 | + | area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |||
| 177 | + | if err := tb.tree.StackNew(oldFocus, newID, area); err != nil { | |||
| 178 | + | tb.mu.Unlock() | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | tb.nextPaneID++ | |||
| 182 | + | placements, ok := layout.Solve(tb.tree.Root, area) | |||
| 183 | + | if !ok { | |||
| 184 | + | _ = tb.tree.Close(newID) | |||
| 185 | + | tb.tree.Focus = oldFocus | |||
| 186 | + | tb.nextPaneID-- | |||
| 187 | + | tb.mu.Unlock() | |||
| 188 | + | return layout.ErrTooSmall | |||
| 189 | + | } | |||
| 190 | + | newRect := placementContent(placements, newID) | |||
| 191 | + | tb.mu.Unlock() | |||
| 192 | + | ||||
| 193 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |||
| 194 | + | if err != nil { | |||
| 195 | + | tb.mu.Lock() | |||
| 196 | + | _ = tb.tree.Close(newID) | |||
| 197 | + | tb.tree.Focus = oldFocus | |||
| 198 | + | tb.nextPaneID-- | |||
| 199 | + | tb.mu.Unlock() | |||
| 200 | + | return err | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | pctx, cancel := context.WithCancel(tb.ctx) | |||
| 204 | + | p := newPane(newID, pty, rectSize(newRect)) | |||
| 205 | + | p.ctx, p.cancel = pctx, cancel | |||
| 206 | + | p.rect = newRect | |||
| 207 | + | ||||
| 208 | + | tb.mu.Lock() | |||
| 209 | + | if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) { | |||
| 210 | + | tb.mu.Unlock() | |||
| 211 | + | cancel() | |||
| 212 | + | _ = pty.Close() | |||
| 213 | + | return layout.ErrNotFound | |||
| 214 | + | } | |||
| 215 | + | tb.panes[newID] = p | |||
| 216 | + | tb.tree.Focus = newID | |||
| 217 | + | d.applyLayoutLocked(tb) | |||
| 218 | + | tb.mu.Unlock() | |||
| 219 | + | ||||
| 220 | + | d.startPaneGoroutines(sess, tb, p) | |||
| 221 | + | if ac != nil { | |||
| 222 | + | d.paint(sess, ac, true) | |||
| 223 | + | } | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 228 | + | if d.ptys == nil { | |||
| 229 | + | if ac != nil { | |||
| 230 | + | d.paint(sess, ac, true) | |||
| 231 | + | } | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | tb := sess.activeTab() | |||
| 235 | + | if tb == nil { | |||
| 236 | + | return layout.ErrNotFound | |||
| 237 | + | } | |||
| 238 | + | tb.mu.Lock() | |||
| 239 | + | err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 240 | + | if err == nil { | |||
| 241 | + | d.applyLayoutLocked(tb) | |||
| 242 | + | } | |||
| 243 | + | tb.mu.Unlock() | |||
| 244 | + | if err == nil && ac != nil { | |||
| 245 | + | d.paint(sess, ac, true) | |||
| 246 | + | } | |||
| 247 | + | return err | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error { | |||
| 251 | + | if d.ptys == nil { | |||
| 252 | + | if ac != nil { | |||
| 253 | + | d.paint(sess, ac, true) | |||
| 254 | + | } | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | tb := sess.activeTab() | |||
| 258 | + | if tb == nil { | |||
| 259 | + | return layout.ErrNotFound | |||
| 260 | + | } | |||
| 261 | + | tb.mu.Lock() | |||
| 262 | + | if tb.tree == nil { | |||
| 263 | + | tb.mu.Unlock() | |||
| 264 | + | return layout.ErrNotFound | |||
| 265 | + | } | |||
| 266 | + | id := tb.tree.Focus | |||
| 267 | + | tb.mu.Unlock() | |||
| 268 | + | return d.closePane(sess, tb, id, ac, true) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) { | |||
| 272 | + | if p == nil { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | _ = d.closePane(sess, tb, p.id, nil, true) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool) error { | |||
| 279 | + | if tb == nil { | |||
| 280 | + | return layout.ErrNotFound | |||
| 281 | + | } | |||
| 282 | + | tb.mu.Lock() | |||
| 283 | + | p := tb.panes[id] | |||
| 284 | + | if p == nil || tb.tree == nil || !layoutContains(tb.tree.Root, id) { | |||
| 285 | + | tb.mu.Unlock() | |||
| 286 | + | return nil | |||
| 287 | + | } | |||
| 288 | + | if len(tb.panes) <= 1 { | |||
| 289 | + | tb.mu.Unlock() | |||
| 290 | + | d.closeTab(sess, tb, repaint) | |||
| 291 | + | return nil | |||
| 292 | + | } | |||
| 293 | + | if err := tb.tree.Close(id); err != nil { | |||
| 294 | + | tb.mu.Unlock() | |||
| 295 | + | return err | |||
| 296 | + | } | |||
| 297 | + | delete(tb.panes, id) | |||
| 298 | + | d.applyLayoutLocked(tb) | |||
| 299 | + | tb.mu.Unlock() | |||
| 300 | + | ||||
| 301 | + | if p.cancel != nil { | |||
| 302 | + | p.cancel() | |||
| 303 | + | } | |||
| 304 | + | _ = p.pty.Close() | |||
| 305 | + | if repaint { | |||
| 306 | + | if ac == nil { | |||
| 307 | + | sess.mu.Lock() | |||
| 308 | + | ac = sess.client | |||
| 309 | + | sess.mu.Unlock() | |||
| 310 | + | } | |||
| 311 | + | if ac != nil { | |||
| 312 | + | d.paint(sess, ac, true) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | return nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction) error { | |||
| 319 | + | tb := sess.activeTab() | |||
| 320 | + | if tb == nil { | |||
| 321 | + | return layout.ErrNotFound | |||
| 322 | + | } | |||
| 323 | + | tb.mu.Lock() | |||
| 324 | + | if tb.tree == nil { | |||
| 325 | + | tb.mu.Unlock() | |||
| 326 | + | return layout.ErrNotFound | |||
| 327 | + | } | |||
| 328 | + | oldFocus := tb.tree.Focus | |||
| 329 | + | err := tb.tree.FocusDir(dir, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 330 | + | newFocus := tb.tree.Focus | |||
| 331 | + | if err == nil { | |||
| 332 | + | d.applyLayoutLocked(tb) | |||
| 333 | + | } | |||
| 334 | + | tb.mu.Unlock() | |||
| 335 | + | if err == nil && ac != nil { | |||
| 336 | + | if newFocus != oldFocus { | |||
| 337 | + | d.exitCopyMode(ac) | |||
| 338 | + | tb.mu.Lock() | |||
| 339 | + | pl, ok := focusedPlacementLocked(tb) | |||
| 340 | + | tb.mu.Unlock() | |||
| 341 | + | if ok && pl.TitleBar.Height > 0 { | |||
| 342 | + | d.refreshPaneTitle(sess, newFocus) | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | d.paint(sess, ac, true) | |||
| 346 | + | } | |||
| 347 | + | if err == layout.ErrNoPane { | |||
| 348 | + | return nil | |||
| 349 | + | } | |||
| 350 | + | return err | |||
| 351 | + | } | |||
A internal/usecase/daemon/pane_actions_test.go +253 -0
| @@ -0,0 +1,253 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | + | "github.com/bnema/vev/pkg/renderer" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | dir layout.Direction | |
| 25 | + | size domain.Size | |
| 26 | + | wantNew domain.Rect | |
| 27 | + | wantOld domain.Rect | |
| 28 | + | isNewSide func(old, new domain.Rect) bool | |
| 29 | + | }{ | |
| 30 | + | {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X > old.X }}, | |
| 31 | + | {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X < old.X }}, | |
| 32 | + | {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y > old.Y }}, | |
| 33 | + | {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y < old.Y }}, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size) | |
| 39 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 40 | + | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | |
| 41 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 42 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once() | |
| 43 | + | ||
| 44 | + | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | |
| 45 | + | ||
| 46 | + | tb := sess.activeTab() | |
| 47 | + | tb.mu.Lock() | |
| 48 | + | defer tb.mu.Unlock() | |
| 49 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 50 | + | require.Len(t, tb.panes, 2) | |
| 51 | + | oldRect := tb.panes["pane-1"].rect | |
| 52 | + | newRect := tb.panes["pane-2"].rect | |
| 53 | + | require.Equal(t, tt.wantOld, oldRect) | |
| 54 | + | require.Equal(t, tt.wantNew, newRect) | |
| 55 | + | require.True(t, tt.isNewSide(oldRect, newRect)) | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |
| 61 | + | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 62 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | |
| 63 | + | ||
| 64 | + | tb := sess.activeTab() | |
| 65 | + | tb.mu.Lock() | |
| 66 | + | beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes) | |
| 67 | + | tb.mu.Unlock() | |
| 68 | + | ||
| 69 | + | require.Error(t, d.splitPane(sess, nil, layout.Right)) | |
| 70 | + | ||
| 71 | + | tb.mu.Lock() | |
| 72 | + | defer tb.mu.Unlock() | |
| 73 | + | require.Equal(t, beforeRoot, tb.tree.Root.Leaf) | |
| 74 | + | require.Equal(t, beforeFocus, tb.tree.Focus) | |
| 75 | + | require.Equal(t, beforeNext, tb.nextPaneID) | |
| 76 | + | require.Len(t, tb.panes, beforePaneCount) | |
| 77 | + | require.Nil(t, tb.panes["pane-2"]) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |
| 81 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 82 | + | tb := sess.activeTab() | |
| 83 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 84 | + | newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10}) | |
| 85 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 86 | + | tb.panes["pane-2"] = newPane | |
| 87 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 88 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 89 | + | ||
| 90 | + | tb.mu.Lock() | |
| 91 | + | d.applyLayoutLocked(tb) | |
| 92 | + | tb.mu.Unlock() | |
| 93 | + | ||
| 94 | + | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |
| 95 | + | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | |
| 96 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestResizeReflowsAllPanes(t *testing.T) { | |
| 100 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 101 | + | tb := sess.activeTab() | |
| 102 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 103 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 104 | + | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 105 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 106 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 107 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once() | |
| 108 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once() | |
| 109 | + | ||
| 110 | + | d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20}) | |
| 111 | + | ||
| 112 | + | require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size) | |
| 113 | + | require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect) | |
| 114 | + | require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect) | |
| 115 | + | require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width) | |
| 116 | + | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |
| 120 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 121 | + | tb := sess.activeTab() | |
| 122 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 123 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 124 | + | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 125 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 126 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 127 | + | tr, _ := newCapturingTransport(t) | |
| 128 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}, copyMode: &scopy.Mode{}} | |
| 129 | + | ||
| 130 | + | require.NoError(t, d.focusDir(sess, ac, layout.Right)) | |
| 131 | + | ||
| 132 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 133 | + | require.Nil(t, ac.copyMode) | |
| 134 | + | oldPTY.AssertNotCalled(t, "Resize", mock.Anything) | |
| 135 | + | newPTY.AssertNotCalled(t, "Resize", mock.Anything) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) { | |
| 139 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 140 | + | tb := sess.activeTab() | |
| 141 | + | closingPTY := portsmocks.NewMockPTY(t) | |
| 142 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 143 | + | tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 144 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 145 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 146 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |
| 147 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |
| 148 | + | ||
| 149 | + | require.NoError(t, d.closeFocusedPane(sess, nil)) | |
| 150 | + | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, true)) | |
| 151 | + | ||
| 152 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 153 | + | require.Len(t, tb.panes, 1) | |
| 154 | + | require.Equal(t, domain.Rect{Width: 41, Height: 10}, tb.panes["pane-1"].rect) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) { | |
| 158 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 159 | + | tb := sess.activeTab() | |
| 160 | + | reapedPTY := portsmocks.NewMockPTY(t) | |
| 161 | + | reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10}) | |
| 162 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |
| 163 | + | tb.panes["pane-2"] = reaped | |
| 164 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 165 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 166 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |
| 167 | + | reapedPTY.EXPECT().Close().Return(nil).Once() | |
| 168 | + | ||
| 169 | + | d.reapPane(sess, tb, reaped) | |
| 170 | + | d.reapPane(sess, tb, reaped) | |
| 171 | + | ||
| 172 | + | require.Len(t, tb.panes, 1) | |
| 173 | + | require.NotContains(t, tb.panes, layout.PaneID("pane-2")) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) { | |
| 177 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 178 | + | otherPTY := portsmocks.NewMockPTY(t) | |
| 179 | + | other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10}) | |
| 180 | + | sess.tabs = append(sess.tabs, other) | |
| 181 | + | oldPTY.EXPECT().Close().Return(nil).Once() | |
| 182 | + | ||
| 183 | + | require.NoError(t, d.closeFocusedPane(sess, nil)) | |
| 184 | + | ||
| 185 | + | require.Len(t, sess.tabs, 1) | |
| 186 | + | require.Same(t, other, sess.tabs[0]) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |
| 190 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | |
| 191 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 192 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 193 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() | |
| 194 | + | ||
| 195 | + | require.NoError(t, d.stackPane(sess, nil)) | |
| 196 | + | tb := sess.activeTab() | |
| 197 | + | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |
| 198 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 199 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |
| 200 | + | require.Len(t, tb.panes, 2) | |
| 201 | + | ||
| 202 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 203 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 204 | + | require.NoError(t, d.toggleStack(sess, nil)) | |
| 205 | + | require.Equal(t, layout.Split, tb.tree.Root.Kind) | |
| 206 | + | require.Equal(t, layout.Vertical, tb.tree.Root.Dir) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |
| 210 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | |
| 211 | + | newPTY := portsmocks.NewMockPTY(t) | |
| 212 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 213 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() | |
| 214 | + | require.NoError(t, d.stackPane(sess, nil)) | |
| 215 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | |
| 216 | + | ||
| 217 | + | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | |
| 218 | + | ||
| 219 | + | tb := sess.activeTab() | |
| 220 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |
| 221 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | |
| 222 | + | thirdPTY := portsmocks.NewMockPTY(t) | |
| 223 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() | |
| 224 | + | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | |
| 225 | + | require.Len(t, tb.panes, 2) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) { | |
| 229 | + | t.Helper() | |
| 230 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 231 | + | clock := portsmocks.NewMockClock(t) | |
| 232 | + | clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe() | |
| 233 | + | d := New(factory, clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithShell("/bin/sh", nil)) | |
| 234 | + | oldPTY := portsmocks.NewMockPTY(t) | |
| 235 | + | tb := newTab(oldPTY, sz) | |
| 236 | + | tctx, cancel := context.WithCancel(context.Background()) | |
| 237 | + | t.Cleanup(cancel) | |
| 238 | + | tb.ctx, tb.cancel = tctx, cancel | |
| 239 | + | for _, p := range tb.panes { | |
| 240 | + | p.ctx, p.cancel = tctx, cancel | |
| 241 | + | } | |
| 242 | + | sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |
| 243 | + | return d, sess, oldPTY, factory | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func blockingRead(t *testing.T) func([]byte) (int, error) { | |
| 247 | + | t.Helper() | |
| 248 | + | ch := make(chan struct{}) | |
| 249 | + | return func([]byte) (int, error) { | |
| 250 | + | <-ch | |
| 251 | + | return 0, io.EOF | |
| 252 | + | } | |
| 253 | + | } | |
| @@ -0,0 +1,253 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 16 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 18 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | dir layout.Direction | |||
| 25 | + | size domain.Size | |||
| 26 | + | wantNew domain.Rect | |||
| 27 | + | wantOld domain.Rect | |||
| 28 | + | isNewSide func(old, new domain.Rect) bool | |||
| 29 | + | }{ | |||
| 30 | + | {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X > old.X }}, | |||
| 31 | + | {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X < old.X }}, | |||
| 32 | + | {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y > old.Y }}, | |||
| 33 | + | {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y < old.Y }}, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, tt.size) | |||
| 39 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 40 | + | oldPTY.EXPECT().Resize(rectSize(tt.wantOld)).Return(nil).Once() | |||
| 41 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 42 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", rectSize(tt.wantNew)).Return(newPTY, nil).Once() | |||
| 43 | + | ||||
| 44 | + | require.NoError(t, d.splitPane(sess, nil, tt.dir)) | |||
| 45 | + | ||||
| 46 | + | tb := sess.activeTab() | |||
| 47 | + | tb.mu.Lock() | |||
| 48 | + | defer tb.mu.Unlock() | |||
| 49 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 50 | + | require.Len(t, tb.panes, 2) | |||
| 51 | + | oldRect := tb.panes["pane-1"].rect | |||
| 52 | + | newRect := tb.panes["pane-2"].rect | |||
| 53 | + | require.Equal(t, tt.wantOld, oldRect) | |||
| 54 | + | require.Equal(t, tt.wantNew, newRect) | |||
| 55 | + | require.True(t, tt.isNewSide(oldRect, newRect)) | |||
| 56 | + | }) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) { | |||
| 61 | + | d, sess, _, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 62 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(nil, errors.New("open failed")).Once() | |||
| 63 | + | ||||
| 64 | + | tb := sess.activeTab() | |||
| 65 | + | tb.mu.Lock() | |||
| 66 | + | beforeRoot, beforeFocus, beforeNext, beforePaneCount := tb.tree.Root.Leaf, tb.tree.Focus, tb.nextPaneID, len(tb.panes) | |||
| 67 | + | tb.mu.Unlock() | |||
| 68 | + | ||||
| 69 | + | require.Error(t, d.splitPane(sess, nil, layout.Right)) | |||
| 70 | + | ||||
| 71 | + | tb.mu.Lock() | |||
| 72 | + | defer tb.mu.Unlock() | |||
| 73 | + | require.Equal(t, beforeRoot, tb.tree.Root.Leaf) | |||
| 74 | + | require.Equal(t, beforeFocus, tb.tree.Focus) | |||
| 75 | + | require.Equal(t, beforeNext, tb.nextPaneID) | |||
| 76 | + | require.Len(t, tb.panes, beforePaneCount) | |||
| 77 | + | require.Nil(t, tb.panes["pane-2"]) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 81 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 82 | + | tb := sess.activeTab() | |||
| 83 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 84 | + | newPane := newPane("pane-2", newPTY, domain.Size{Cols: 41, Rows: 10}) | |||
| 85 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 86 | + | tb.panes["pane-2"] = newPane | |||
| 87 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |||
| 88 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |||
| 89 | + | ||||
| 90 | + | tb.mu.Lock() | |||
| 91 | + | d.applyLayoutLocked(tb) | |||
| 92 | + | tb.mu.Unlock() | |||
| 93 | + | ||||
| 94 | + | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |||
| 95 | + | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | |||
| 96 | + | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 10}, tb.panes["pane-2"].rect) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestResizeReflowsAllPanes(t *testing.T) { | |||
| 100 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 101 | + | tb := sess.activeTab() | |||
| 102 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 103 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 104 | + | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 105 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |||
| 106 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |||
| 107 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 30, Rows: 18}).Return(nil).Once() | |||
| 108 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 29, Rows: 18}).Return(nil).Once() | |||
| 109 | + | ||||
| 110 | + | d.resize(sess, nil, domain.Size{Cols: 60, Rows: 20}) | |||
| 111 | + | ||||
| 112 | + | require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size) | |||
| 113 | + | require.Equal(t, domain.Rect{Width: 30, Height: 18}, tb.panes["pane-1"].rect) | |||
| 114 | + | require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, tb.panes["pane-2"].rect) | |||
| 115 | + | require.Equal(t, 30, tb.panes["pane-1"].screen.Frame.Width) | |||
| 116 | + | require.Equal(t, 29, tb.panes["pane-2"].screen.Frame.Width) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 120 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 121 | + | tb := sess.activeTab() | |||
| 122 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 123 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 124 | + | tb.panes["pane-2"] = newPane("pane-2", newPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 125 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |||
| 126 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |||
| 127 | + | tr, _ := newCapturingTransport(t) | |||
| 128 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}, copyMode: &scopy.Mode{}} | |||
| 129 | + | ||||
| 130 | + | require.NoError(t, d.focusDir(sess, ac, layout.Right)) | |||
| 131 | + | ||||
| 132 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 133 | + | require.Nil(t, ac.copyMode) | |||
| 134 | + | oldPTY.AssertNotCalled(t, "Resize", mock.Anything) | |||
| 135 | + | newPTY.AssertNotCalled(t, "Resize", mock.Anything) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestCloseFocusedPaneRemovesPaneReflowsAndIsIdempotent(t *testing.T) { | |||
| 139 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 140 | + | tb := sess.activeTab() | |||
| 141 | + | closingPTY := portsmocks.NewMockPTY(t) | |||
| 142 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 143 | + | tb.panes["pane-2"] = newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 144 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |||
| 145 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |||
| 146 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |||
| 147 | + | closingPTY.EXPECT().Close().Return(nil).Once() | |||
| 148 | + | ||||
| 149 | + | require.NoError(t, d.closeFocusedPane(sess, nil)) | |||
| 150 | + | require.NoError(t, d.closePane(sess, tb, "pane-2", nil, true)) | |||
| 151 | + | ||||
| 152 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |||
| 153 | + | require.Len(t, tb.panes, 1) | |||
| 154 | + | require.Equal(t, domain.Rect{Width: 41, Height: 10}, tb.panes["pane-1"].rect) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) { | |||
| 158 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 159 | + | tb := sess.activeTab() | |||
| 160 | + | reapedPTY := portsmocks.NewMockPTY(t) | |||
| 161 | + | reaped := newPane("pane-2", reapedPTY, domain.Size{Cols: 20, Rows: 10}) | |||
| 162 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"} | |||
| 163 | + | tb.panes["pane-2"] = reaped | |||
| 164 | + | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |||
| 165 | + | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |||
| 166 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 41, Rows: 10}).Return(nil).Once() | |||
| 167 | + | reapedPTY.EXPECT().Close().Return(nil).Once() | |||
| 168 | + | ||||
| 169 | + | d.reapPane(sess, tb, reaped) | |||
| 170 | + | d.reapPane(sess, tb, reaped) | |||
| 171 | + | ||||
| 172 | + | require.Len(t, tb.panes, 1) | |||
| 173 | + | require.NotContains(t, tb.panes, layout.PaneID("pane-2")) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) { | |||
| 177 | + | d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |||
| 178 | + | otherPTY := portsmocks.NewMockPTY(t) | |||
| 179 | + | other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10}) | |||
| 180 | + | sess.tabs = append(sess.tabs, other) | |||
| 181 | + | oldPTY.EXPECT().Close().Return(nil).Once() | |||
| 182 | + | ||||
| 183 | + | require.NoError(t, d.closeFocusedPane(sess, nil)) | |||
| 184 | + | ||||
| 185 | + | require.Len(t, sess.tabs, 1) | |||
| 186 | + | require.Same(t, other, sess.tabs[0]) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||
| 190 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | |||
| 191 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 192 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 193 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() | |||
| 194 | + | ||||
| 195 | + | require.NoError(t, d.stackPane(sess, nil)) | |||
| 196 | + | tb := sess.activeTab() | |||
| 197 | + | require.Equal(t, layout.Stack, tb.tree.Root.Kind) | |||
| 198 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 199 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Root.Expanded) | |||
| 200 | + | require.Len(t, tb.panes, 2) | |||
| 201 | + | ||||
| 202 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |||
| 203 | + | newPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |||
| 204 | + | require.NoError(t, d.toggleStack(sess, nil)) | |||
| 205 | + | require.Equal(t, layout.Split, tb.tree.Root.Kind) | |||
| 206 | + | require.Equal(t, layout.Vertical, tb.tree.Root.Dir) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 210 | + | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | |||
| 211 | + | newPTY := portsmocks.NewMockPTY(t) | |||
| 212 | + | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |||
| 213 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() | |||
| 214 | + | require.NoError(t, d.stackPane(sess, nil)) | |||
| 215 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | |||
| 216 | + | ||||
| 217 | + | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | |||
| 218 | + | ||||
| 219 | + | tb := sess.activeTab() | |||
| 220 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus) | |||
| 221 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Root.Expanded) | |||
| 222 | + | thirdPTY := portsmocks.NewMockPTY(t) | |||
| 223 | + | factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", mock.Anything).Return(thirdPTY, nil).Maybe() | |||
| 224 | + | require.ErrorIs(t, d.stackPane(sess, nil), layout.ErrTooSmall) | |||
| 225 | + | require.Len(t, tb.panes, 2) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *portsmocks.MockPTY, *portsmocks.MockPTYFactory) { | |||
| 229 | + | t.Helper() | |||
| 230 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 231 | + | clock := portsmocks.NewMockClock(t) | |||
| 232 | + | clock.EXPECT().Now().Return(time.Unix(0, 0)).Maybe() | |||
| 233 | + | d := New(factory, clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithShell("/bin/sh", nil)) | |||
| 234 | + | oldPTY := portsmocks.NewMockPTY(t) | |||
| 235 | + | tb := newTab(oldPTY, sz) | |||
| 236 | + | tctx, cancel := context.WithCancel(context.Background()) | |||
| 237 | + | t.Cleanup(cancel) | |||
| 238 | + | tb.ctx, tb.cancel = tctx, cancel | |||
| 239 | + | for _, p := range tb.panes { | |||
| 240 | + | p.ctx, p.cancel = tctx, cancel | |||
| 241 | + | } | |||
| 242 | + | sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |||
| 243 | + | return d, sess, oldPTY, factory | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func blockingRead(t *testing.T) func([]byte) (int, error) { | |||
| 247 | + | t.Helper() | |||
| 248 | + | ch := make(chan struct{}) | |||
| 249 | + | return func([]byte) (int, error) { | |||
| 250 | + | <-ch | |||
| 251 | + | return 0, io.EOF | |||
| 252 | + | } | |||
| 253 | + | } | |||
A internal/usecase/daemon/pane_titles.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string { | |
| 11 | + | fallback := filepath.Base(d.shell) | |
| 12 | + | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |
| 13 | + | fallback = d.shell | |
| 14 | + | } | |
| 15 | + | if sess == nil { | |
| 16 | + | return fallback | |
| 17 | + | } | |
| 18 | + | tb := sess.activeTab() | |
| 19 | + | if tb == nil { | |
| 20 | + | return fallback | |
| 21 | + | } | |
| 22 | + | tb.mu.Lock() | |
| 23 | + | p := tb.panes[id] | |
| 24 | + | tb.mu.Unlock() | |
| 25 | + | if p == nil { | |
| 26 | + | return fallback | |
| 27 | + | } | |
| 28 | + | title := fallback | |
| 29 | + | if d.procComm != nil && p.pty != nil { | |
| 30 | + | if pgid, err := foregroundPgid(p.pty); err == nil && pgid > 0 { | |
| 31 | + | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | |
| 32 | + | title = strings.TrimSpace(comm) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | p.mu.Lock() | |
| 37 | + | p.title = title | |
| 38 | + | p.mu.Unlock() | |
| 39 | + | return title | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func foregroundPgid(pty interface{ ForegroundPgid() (int, error) }) (pgid int, err error) { | |
| 43 | + | defer func() { | |
| 44 | + | if recover() != nil { | |
| 45 | + | pgid = 0 | |
| 46 | + | } | |
| 47 | + | }() | |
| 48 | + | return pty.ForegroundPgid() | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string { | |||
| 11 | + | fallback := filepath.Base(d.shell) | |||
| 12 | + | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |||
| 13 | + | fallback = d.shell | |||
| 14 | + | } | |||
| 15 | + | if sess == nil { | |||
| 16 | + | return fallback | |||
| 17 | + | } | |||
| 18 | + | tb := sess.activeTab() | |||
| 19 | + | if tb == nil { | |||
| 20 | + | return fallback | |||
| 21 | + | } | |||
| 22 | + | tb.mu.Lock() | |||
| 23 | + | p := tb.panes[id] | |||
| 24 | + | tb.mu.Unlock() | |||
| 25 | + | if p == nil { | |||
| 26 | + | return fallback | |||
| 27 | + | } | |||
| 28 | + | title := fallback | |||
| 29 | + | if d.procComm != nil && p.pty != nil { | |||
| 30 | + | if pgid, err := foregroundPgid(p.pty); err == nil && pgid > 0 { | |||
| 31 | + | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | |||
| 32 | + | title = strings.TrimSpace(comm) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | p.mu.Lock() | |||
| 37 | + | p.title = title | |||
| 38 | + | p.mu.Unlock() | |||
| 39 | + | return title | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func foregroundPgid(pty interface{ ForegroundPgid() (int, error) }) (pgid int, err error) { | |||
| 43 | + | defer func() { | |||
| 44 | + | if recover() != nil { | |||
| 45 | + | pgid = 0 | |||
| 46 | + | } | |||
| 47 | + | }() | |||
| 48 | + | return pty.ForegroundPgid() | |||
| 49 | + | } | |||
A internal/usecase/daemon/pane_titles_test.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | |
| 13 | + | pty := portsmocks.NewMockPTY(t) | |
| 14 | + | pty.EXPECT().ForegroundPgid().Return(1234, nil).Once() | |
| 15 | + | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 16 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 17 | + | d.shell = "/bin/zsh" | |
| 18 | + | d.procComm = func(pid int) (string, error) { | |
| 19 | + | require.Equal(t, 1234, pid) | |
| 20 | + | return "vim\n", nil | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | title := d.refreshPaneTitle(sess, "pane-1") | |
| 24 | + | ||
| 25 | + | require.Equal(t, "vim", title) | |
| 26 | + | require.Equal(t, "vim", sess.activeTab().focusedPane().title) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) { | |
| 30 | + | pty := portsmocks.NewMockPTY(t) | |
| 31 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once() | |
| 32 | + | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 33 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 34 | + | d.shell = "/usr/bin/fish" | |
| 35 | + | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | |
| 36 | + | ||
| 37 | + | title := d.refreshPaneTitle(sess, "pane-1") | |
| 38 | + | ||
| 39 | + | require.Equal(t, "fish", title) | |
| 40 | + | require.Equal(t, "fish", sess.activeTab().focusedPane().title) | |
| 41 | + | } | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | |||
| 13 | + | pty := portsmocks.NewMockPTY(t) | |||
| 14 | + | pty.EXPECT().ForegroundPgid().Return(1234, nil).Once() | |||
| 15 | + | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 16 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 17 | + | d.shell = "/bin/zsh" | |||
| 18 | + | d.procComm = func(pid int) (string, error) { | |||
| 19 | + | require.Equal(t, 1234, pid) | |||
| 20 | + | return "vim\n", nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | title := d.refreshPaneTitle(sess, "pane-1") | |||
| 24 | + | ||||
| 25 | + | require.Equal(t, "vim", title) | |||
| 26 | + | require.Equal(t, "vim", sess.activeTab().focusedPane().title) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) { | |||
| 30 | + | pty := portsmocks.NewMockPTY(t) | |||
| 31 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once() | |||
| 32 | + | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 33 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 34 | + | d.shell = "/usr/bin/fish" | |||
| 35 | + | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | |||
| 36 | + | ||||
| 37 | + | title := d.refreshPaneTitle(sess, "pane-1") | |||
| 38 | + | ||||
| 39 | + | require.Equal(t, "fish", title) | |||
| 40 | + | require.Equal(t, "fish", sess.activeTab().focusedPane().title) | |||
| 41 | + | } | |||
M internal/usecase/daemon/picker.go +18 -9
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -500,9 +499,19 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||
| 500 | 499 | } | |
| 501 | 500 | ||
| 502 | 501 | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | |
| 503 | - | rows := make([][]renderer.Cell, tb.screen.Frame.Height) | |
| 502 | + | p := tb.focusedPane() | |
| 503 | + | if p == nil { | |
| 504 | + | return picker.Preview{} | |
| 505 | + | } | |
| 506 | + | p.mu.Lock() | |
| 507 | + | defer p.mu.Unlock() | |
| 508 | + | return pickerPreviewFromLockedPane(p) | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | |
| 512 | + | rows := make([][]renderer.Cell, p.screen.Frame.Height) | |
| 504 | 513 | for y := range rows { | |
| 505 | - | rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...) | |
| 514 | + | rows[y] = append([]renderer.Cell(nil), p.screen.Frame.Row(y)...) | |
| 506 | 515 | } | |
| 507 | - | return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height} | |
| 516 | + | return picker.Preview{Rows: rows, Width: p.screen.Frame.Width, Height: p.screen.Frame.Height} | |
| 508 | 517 | } | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| @@ -500,9 +499,19 @@ func snapshotPickerPreview(tb *tab) picker.Preview { | |||||
| 500 | } | 499 | } | ||
| 501 | 500 | ||||
| 502 | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | 501 | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | ||
| 503 | - | rows := make([][]renderer.Cell, tb.screen.Frame.Height) | 502 | + | p := tb.focusedPane() |
| 503 | + | if p == nil { | |||
| 504 | + | return picker.Preview{} | |||
| 505 | + | } | |||
| 506 | + | p.mu.Lock() | |||
| 507 | + | defer p.mu.Unlock() | |||
| 508 | + | return pickerPreviewFromLockedPane(p) | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func pickerPreviewFromLockedPane(p *pane) picker.Preview { | |||
| 512 | + | rows := make([][]renderer.Cell, p.screen.Frame.Height) | |||
| 504 | for y := range rows { | 513 | for y := range rows { | ||
| 505 | - | rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...) | 514 | + | rows[y] = append([]renderer.Cell(nil), p.screen.Frame.Row(y)...) |
| 506 | } | 515 | } | ||
| 507 | - | return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height} | 516 | + | return picker.Preview{Rows: rows, Width: p.screen.Frame.Width, Height: p.screen.Frame.Height} |
| 508 | } | 517 | } | ||
M internal/usecase/daemon/picker_test.go +6 -1
| @@ -270,8 +270,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||
| 270 | 270 | tb := sess.tabs[i%len(sess.tabs)] | |
| 271 | 271 | wg.Go(func() { | |
| 272 | 272 | tb.mu.Lock() | |
| 273 | - | tb.screen.Write([]byte("render-race")) | |
| 273 | + | p := tb.focusedPane() | |
| 274 | 274 | tb.mu.Unlock() | |
| 275 | + | if p != nil { | |
| 276 | + | p.mu.Lock() | |
| 277 | + | p.screen.Write([]byte("render-race")) | |
| 278 | + | p.mu.Unlock() | |
| 279 | + | } | |
| 275 | 280 | d.render(sess, tb) | |
| 276 | 281 | }) | |
| 277 | 282 | } | |
| @@ -270,8 +270,13 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||||
| 270 | tb := sess.tabs[i%len(sess.tabs)] | 270 | tb := sess.tabs[i%len(sess.tabs)] | ||
| 271 | wg.Go(func() { | 271 | wg.Go(func() { | ||
| 272 | tb.mu.Lock() | 272 | tb.mu.Lock() | ||
| 273 | - | tb.screen.Write([]byte("render-race")) | 273 | + | p := tb.focusedPane() |
| 274 | tb.mu.Unlock() | 274 | tb.mu.Unlock() | ||
| 275 | + | if p != nil { | |||
| 276 | + | p.mu.Lock() | |||
| 277 | + | p.screen.Write([]byte("render-race")) | |||
| 278 | + | p.mu.Unlock() | |||
| 279 | + | } | |||
| 275 | d.render(sess, tb) | 280 | d.render(sess, tb) | ||
| 276 | }) | 281 | }) | ||
| 277 | } | 282 | } | ||
M internal/usecase/daemon/render.go +246 -69
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -30,8 +29,11 @@ import ( | |||
| 30 | 29 | "strconv" | |
| 31 | 30 | "time" | |
| 32 | 31 | ||
| 32 | + | "github.com/bnema/vev/internal/domain" | |
| 33 | 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 34 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 34 | 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 36 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 35 | 37 | "github.com/bnema/vev/pkg/renderer" | |
| 36 | 38 | "github.com/bnema/vev/pkg/vt" | |
| 37 | 39 | ) | |
| @@ -43,56 +45,65 @@ func signal(ch chan struct{}) { | |||
| 43 | 45 | } | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | - | func (d *Daemon) ptyReader(sess *session, tb *tab) { | |
| 48 | + | func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) { | |
| 47 | 49 | defer d.sessWg.Done() | |
| 50 | + | var p *pane | |
| 51 | + | if len(panes) > 0 { | |
| 52 | + | p = panes[0] | |
| 53 | + | } else { | |
| 54 | + | p = tb.focusedPane() | |
| 55 | + | } | |
| 56 | + | if p == nil { | |
| 57 | + | return | |
| 58 | + | } | |
| 48 | 59 | buf := make([]byte, ptyReadBufSize) | |
| 49 | 60 | var resp []byte | |
| 50 | 61 | attentionCh := make(chan struct{}, 1) | |
| 51 | - | tb.mu.Lock() | |
| 52 | - | tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 53 | - | tb.screen.OnBell = func() { signal(attentionCh) } | |
| 54 | - | tb.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 55 | - | tb.mu.Unlock() | |
| 62 | + | p.mu.Lock() | |
| 63 | + | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 64 | + | p.screen.OnBell = func() { signal(attentionCh) } | |
| 65 | + | p.screen.OnNotify = func(string, string) { signal(attentionCh) } | |
| 66 | + | p.mu.Unlock() | |
| 56 | 67 | for { | |
| 57 | - | n, err := tb.pty.Read(buf) | |
| 68 | + | n, err := p.pty.Read(buf) | |
| 58 | 69 | if n > 0 { | |
| 59 | 70 | data := buf[:n] | |
| 60 | - | tb.mu.Lock() | |
| 61 | - | wasSyncing := tb.screen.SyncUpdateActive() | |
| 62 | - | tb.screen.Write(data) | |
| 63 | - | isSyncing := tb.screen.SyncUpdateActive() | |
| 64 | - | tb.mu.Unlock() | |
| 71 | + | p.mu.Lock() | |
| 72 | + | wasSyncing := p.screen.SyncUpdateActive() | |
| 73 | + | p.screen.Write(data) | |
| 74 | + | isSyncing := p.screen.SyncUpdateActive() | |
| 75 | + | p.mu.Unlock() | |
| 65 | 76 | select { | |
| 66 | 77 | case <-attentionCh: | |
| 67 | 78 | d.noteAttention(sess, tb) | |
| 68 | 79 | default: | |
| 69 | 80 | } | |
| 70 | 81 | if len(resp) > 0 { | |
| 71 | - | if _, writeErr := tb.pty.Write(resp); writeErr != nil { | |
| 82 | + | if _, writeErr := p.pty.Write(resp); writeErr != nil { | |
| 72 | 83 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |
| 73 | 84 | } | |
| 74 | 85 | resp = resp[:0] | |
| 75 | 86 | } | |
| 76 | 87 | if wasSyncing != isSyncing { | |
| 77 | - | tb.mu.Lock() | |
| 78 | - | tb.syncGen++ | |
| 79 | - | gen := tb.syncGen | |
| 80 | - | tb.mu.Unlock() | |
| 88 | + | p.mu.Lock() | |
| 89 | + | p.syncGen++ | |
| 90 | + | gen := p.syncGen | |
| 91 | + | p.mu.Unlock() | |
| 81 | 92 | if isSyncing { | |
| 82 | - | go d.syncWatchdog(tb, gen) | |
| 93 | + | go d.syncWatchdog(p, gen) | |
| 83 | 94 | } | |
| 84 | 95 | } | |
| 85 | 96 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | |
| 86 | - | signal(tb.flush) | |
| 97 | + | signal(p.flush) | |
| 87 | 98 | continue | |
| 88 | 99 | } | |
| 89 | 100 | if isSyncing { | |
| 90 | 101 | continue | |
| 91 | 102 | } | |
| 92 | - | signal(tb.dirty) | |
| 103 | + | signal(p.dirty) | |
| 93 | 104 | } | |
| 94 | 105 | if err != nil { | |
| 95 | - | d.closeTab(sess, tb, true) | |
| 106 | + | d.reapPane(sess, tb, p) | |
| 96 | 107 | return | |
| 97 | 108 | } | |
| 98 | 109 | } | |
| @@ -101,8 +112,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) { | |||
| 101 | 112 | // scheduler debounces dirty signals. The first dirty opens a short tab; | |
| 102 | 113 | // sustained floods progressively widen that tab, while isolated updates | |
| 103 | 114 | // return to the minimum delay for interactive latency. | |
| 104 | - | func (d *Daemon) scheduler(sess *session, tb *tab) { | |
| 115 | + | func (d *Daemon) scheduler(sess *session, tb *tab, panes ...*pane) { | |
| 105 | 116 | defer d.sessWg.Done() | |
| 117 | + | var p *pane | |
| 118 | + | if len(panes) > 0 { | |
| 119 | + | p = panes[0] | |
| 120 | + | } else { | |
| 121 | + | p = tb.focusedPane() | |
| 122 | + | } | |
| 123 | + | if p == nil { | |
| 124 | + | return | |
| 125 | + | } | |
| 106 | 126 | delay := minDebounceInterval | |
| 107 | 127 | lastRender := d.clock.Now() | |
| 108 | 128 | outer: | |
| @@ -110,13 +130,13 @@ outer: | |||
| 110 | 130 | select { | |
| 111 | 131 | case <-sess.ctx.Done(): | |
| 112 | 132 | return | |
| 113 | - | case <-tabDone(tb): | |
| 133 | + | case <-paneDone(p): | |
| 114 | 134 | return | |
| 115 | - | case <-tb.flush: | |
| 135 | + | case <-p.flush: | |
| 116 | 136 | d.render(sess, tb) | |
| 117 | 137 | lastRender = d.clock.Now() | |
| 118 | 138 | continue | |
| 119 | - | case <-tb.dirty: | |
| 139 | + | case <-p.dirty: | |
| 120 | 140 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | |
| 121 | 141 | delay = minDebounceInterval | |
| 122 | 142 | } | |
| @@ -130,10 +150,10 @@ outer: | |||
| 130 | 150 | case <-sess.ctx.Done(): | |
| 131 | 151 | timer.Stop() | |
| 132 | 152 | return | |
| 133 | - | case <-tabDone(tb): | |
| 153 | + | case <-paneDone(p): | |
| 134 | 154 | timer.Stop() | |
| 135 | 155 | return | |
| 136 | - | case <-tb.flush: | |
| 156 | + | case <-p.flush: | |
| 137 | 157 | if !timer.Stop() { | |
| 138 | 158 | select { | |
| 139 | 159 | case <-timer.C(): | |
| @@ -143,7 +163,7 @@ outer: | |||
| 143 | 163 | d.render(sess, tb) | |
| 144 | 164 | lastRender = d.clock.Now() | |
| 145 | 165 | continue outer | |
| 146 | - | case <-tb.dirty: | |
| 166 | + | case <-p.dirty: | |
| 147 | 167 | coalesced++ | |
| 148 | 168 | case <-timer.C(): | |
| 149 | 169 | break absorb | |
| @@ -169,30 +189,23 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||
| 169 | 189 | return delay | |
| 170 | 190 | } | |
| 171 | 191 | ||
| 172 | - | func (d *Daemon) syncWatchdog(tb *tab, gen uint64) { | |
| 192 | + | func (d *Daemon) syncWatchdog(p *pane, gen uint64) { | |
| 173 | 193 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | |
| 174 | 194 | select { | |
| 175 | - | case <-tabDone(tb): | |
| 195 | + | case <-paneDone(p): | |
| 176 | 196 | timer.Stop() | |
| 177 | 197 | return | |
| 178 | 198 | case <-timer.C(): | |
| 179 | 199 | } | |
| 180 | 200 | ||
| 181 | - | tb.mu.Lock() | |
| 182 | - | if tb.syncGen != gen || !tb.screen.SyncUpdateActive() { | |
| 183 | - | tb.mu.Unlock() | |
| 201 | + | p.mu.Lock() | |
| 202 | + | if p.syncGen != gen || !p.screen.SyncUpdateActive() { | |
| 203 | + | p.mu.Unlock() | |
| 184 | 204 | return | |
| 185 | 205 | } | |
| 186 | - | tb.screen.ForceSyncEnd() | |
| 187 | - | tb.mu.Unlock() | |
| 188 | - | signal(tb.flush) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | func tabDone(tb *tab) <-chan struct{} { | |
| 192 | - | if tb.ctx == nil { | |
| 193 | - | return nil | |
| 194 | - | } | |
| 195 | - | return tb.ctx.Done() | |
| 206 | + | p.screen.ForceSyncEnd() | |
| 207 | + | p.mu.Unlock() | |
| 208 | + | signal(p.flush) | |
| 196 | 209 | } | |
| 197 | 210 | ||
| 198 | 211 | func syncUpdateEndIn(data []byte) bool { | |
| @@ -203,15 +216,18 @@ func syncUpdateEndIn(data []byte) bool { | |||
| 203 | 216 | // damage so it never grows unbounded while headless. | |
| 204 | 217 | func (d *Daemon) render(sess *session, tb *tab) { | |
| 205 | 218 | tb.mu.Lock() | |
| 206 | - | if tb.screen.SyncUpdateActive() { | |
| 207 | - | tb.mu.Unlock() | |
| 208 | - | return | |
| 209 | - | } | |
| 210 | - | tb.mu.Unlock() | |
| 211 | - | ||
| 212 | - | tb.mu.Lock() | |
| 219 | + | p := tb.focusedPane() | |
| 213 | 220 | previewer := tb.previewClient | |
| 214 | 221 | tb.mu.Unlock() | |
| 222 | + | if p == nil { | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | p.mu.Lock() | |
| 226 | + | if p.screen.SyncUpdateActive() { | |
| 227 | + | p.mu.Unlock() | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | p.mu.Unlock() | |
| 215 | 231 | ||
| 216 | 232 | sess.mu.Lock() | |
| 217 | 233 | ac := sess.client | |
| @@ -226,16 +242,16 @@ func (d *Daemon) render(sess *session, tb *tab) { | |||
| 226 | 242 | d.paint(sess, ac, false) | |
| 227 | 243 | return | |
| 228 | 244 | } | |
| 229 | - | tb.mu.Lock() | |
| 230 | - | tb.screen.ClearDamage() | |
| 231 | - | tb.mu.Unlock() | |
| 245 | + | p.mu.Lock() | |
| 246 | + | p.screen.ClearDamage() | |
| 247 | + | p.mu.Unlock() | |
| 232 | 248 | return | |
| 233 | 249 | } | |
| 234 | 250 | ||
| 235 | 251 | if ac == nil || !active { | |
| 236 | - | tb.mu.Lock() | |
| 237 | - | tb.screen.ClearDamage() | |
| 238 | - | tb.mu.Unlock() | |
| 252 | + | p.mu.Lock() | |
| 253 | + | p.screen.ClearDamage() | |
| 254 | + | p.mu.Unlock() | |
| 239 | 255 | return | |
| 240 | 256 | } | |
| 241 | 257 | d.paint(sess, ac, false) | |
| @@ -288,8 +304,27 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 288 | 304 | bars.theme = ac.getTheme() | |
| 289 | 305 | ||
| 290 | 306 | styles := newThemeStyles(ac.getTheme()) | |
| 307 | + | tb.mu.Lock() | |
| 308 | + | var titleIDs []layout.PaneID | |
| 309 | + | if placements, ok := solvedPlacementsLocked(tb); ok { | |
| 310 | + | for _, pl := range placements { | |
| 311 | + | if pl.TitleBar.Height > 0 { | |
| 312 | + | titleIDs = append(titleIDs, pl.ID) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | tb.mu.Unlock() | |
| 317 | + | for _, id := range titleIDs { | |
| 318 | + | d.refreshPaneTitle(sess, id) | |
| 319 | + | } | |
| 291 | 320 | ||
| 292 | 321 | tb.mu.Lock() | |
| 322 | + | p := tb.focusedPane() | |
| 323 | + | if p == nil { | |
| 324 | + | tb.mu.Unlock() | |
| 325 | + | ac.sendMu.Unlock() | |
| 326 | + | return | |
| 327 | + | } | |
| 293 | 328 | if reset || copyActive || pickerActive || paletteActive || promptActive { | |
| 294 | 329 | ac.rend.Reset() | |
| 295 | 330 | ac.bars.Reset() | |
| @@ -303,7 +338,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 303 | 338 | } | |
| 304 | 339 | if pickerActive { | |
| 305 | 340 | if previewTab == tb { | |
| 306 | - | preview = pickerPreviewFromLockedTab(tb) | |
| 341 | + | p.mu.Lock() | |
| 342 | + | preview = pickerPreviewFromLockedPane(p) | |
| 343 | + | p.mu.Unlock() | |
| 307 | 344 | } | |
| 308 | 345 | frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles) | |
| 309 | 346 | } | |
| @@ -315,13 +352,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 315 | 352 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | |
| 316 | 353 | ac.promptMu.Unlock() | |
| 317 | 354 | } | |
| 318 | - | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive) | |
| 355 | + | p.mu.Lock() | |
| 356 | + | desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive) | |
| 357 | + | p.mu.Unlock() | |
| 319 | 358 | data, err := ac.rend.Draw(frame, damage) | |
| 320 | 359 | var cursorTail []byte | |
| 321 | 360 | if err == nil { | |
| 322 | 361 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 323 | 362 | } | |
| 324 | - | tb.screen.ClearDamage() | |
| 363 | + | for _, pane := range tb.panesSnapshot() { | |
| 364 | + | pane.mu.Lock() | |
| 365 | + | pane.screen.ClearDamage() | |
| 366 | + | pane.mu.Unlock() | |
| 367 | + | } | |
| 325 | 368 | tb.mu.Unlock() | |
| 326 | 369 | ||
| 327 | 370 | var serr error | |
| @@ -425,13 +468,18 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||
| 425 | 468 | if len(caches) > 0 { | |
| 426 | 469 | cache = caches[0] | |
| 427 | 470 | } | |
| 471 | + | p := tb.focusedPane() | |
| 428 | 472 | styles := newThemeStyles(bars.theme) | |
| 429 | - | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 473 | + | width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height | |
| 474 | + | if tb.size.Valid() { | |
| 475 | + | width, screenRows = tb.size.Cols, tb.size.Rows | |
| 476 | + | } | |
| 430 | 477 | frame := renderer.NewFrame(width, screenRows+2) | |
| 431 | 478 | topBar := frame.Row(0) | |
| 432 | 479 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles) | |
| 480 | + | contentFrame, contentDamage := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |
| 433 | 481 | for y := range screenRows { | |
| 434 | - | copy(frame.Row(y+1), tb.screen.Frame.Row(y)) | |
| 482 | + | copy(frame.Row(y+1), contentFrame.Row(y)) | |
| 435 | 483 | } | |
| 436 | 484 | bottomBar := frame.Row(screenRows + 1) | |
| 437 | 485 | drawStatusBarState(bottomBar, bars, styles) | |
| @@ -441,7 +489,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||
| 441 | 489 | } | |
| 442 | 490 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 443 | 491 | } | |
| 444 | - | damage := offsetDamage(tb.screen.Damage()) | |
| 492 | + | damage := translateDamage(contentDamage, 0, 1) | |
| 445 | 493 | if cache == nil || !sameCells(cache.top, topBar) { | |
| 446 | 494 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 447 | 495 | } | |
| @@ -454,6 +502,130 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||
| 454 | 502 | return frame, damage | |
| 455 | 503 | } | |
| 456 | 504 | ||
| 505 | + | func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) { | |
| 506 | + | frame := renderer.NewFrame(area.Width, area.Height) | |
| 507 | + | var root *layout.Node | |
| 508 | + | if tb.tree != nil { | |
| 509 | + | root = tb.tree.Root | |
| 510 | + | } | |
| 511 | + | placements, ok := layout.Solve(root, area) | |
| 512 | + | var fallback *pane | |
| 513 | + | if !ok { | |
| 514 | + | fallback = tb.focusedPane() | |
| 515 | + | if fallback == nil { | |
| 516 | + | return frame, nil | |
| 517 | + | } | |
| 518 | + | placements = []layout.Placement{{ID: fallback.id, Content: area}} | |
| 519 | + | } | |
| 520 | + | drawDividers(frame, root, area, newThemeStyles(theme).border) | |
| 521 | + | var damage []renderer.Damage | |
| 522 | + | for _, pl := range placements { | |
| 523 | + | p := tb.panes[pl.ID] | |
| 524 | + | if len(tb.panes) == 1 && tb.tree != nil && tb.tree.Focus == pl.ID { | |
| 525 | + | p = tb.focusedPane() | |
| 526 | + | } | |
| 527 | + | if p == nil && fallback != nil && pl.ID == fallback.id { | |
| 528 | + | p = fallback | |
| 529 | + | } | |
| 530 | + | if p == nil { | |
| 531 | + | continue | |
| 532 | + | } | |
| 533 | + | focused := tb.tree == nil || tb.tree.Focus == pl.ID | |
| 534 | + | if pl.TitleBar.Height > 0 { | |
| 535 | + | drawPaneTitleBar(frame, pl, p, focused, theme, "") | |
| 536 | + | } | |
| 537 | + | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 538 | + | continue | |
| 539 | + | } | |
| 540 | + | p.mu.Lock() | |
| 541 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |
| 542 | + | for _, d := range p.screen.Damage() { | |
| 543 | + | damage = append(damage, translateDamage([]renderer.Damage{d}, pl.Content.X, pl.Content.Y)...) | |
| 544 | + | } | |
| 545 | + | p.mu.Unlock() | |
| 546 | + | } | |
| 547 | + | return frame, damage | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | |
| 551 | + | rows := min(r.Height, src.Height) | |
| 552 | + | cols := min(r.Width, src.Width) | |
| 553 | + | for y := range rows { | |
| 554 | + | for x := range cols { | |
| 555 | + | cell := src.At(x, y) | |
| 556 | + | if dim { | |
| 557 | + | cell.Style = themeui.DimStyle(cell.Style, theme) | |
| 558 | + | } | |
| 559 | + | dst.Set(r.X+x, r.Y+y, cell) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) { | |
| 565 | + | styles := newThemeStyles(theme) | |
| 566 | + | style := styles.border | |
| 567 | + | if focused { | |
| 568 | + | style = styles.accent | |
| 569 | + | } else { | |
| 570 | + | style = themeui.DimStyle(style, theme) | |
| 571 | + | } | |
| 572 | + | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| 573 | + | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| 574 | + | } | |
| 575 | + | title := p.title | |
| 576 | + | if title == "" { | |
| 577 | + | title = fallback | |
| 578 | + | } | |
| 579 | + | if title == "" { | |
| 580 | + | title = string(p.id) | |
| 581 | + | } | |
| 582 | + | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | |
| 586 | + | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | |
| 587 | + | return | |
| 588 | + | } | |
| 589 | + | count := len(n.Children) | |
| 590 | + | if n.Dir == layout.Horizontal { | |
| 591 | + | usable := r.Width - (count - 1) | |
| 592 | + | base, rem := usable/count, usable%count | |
| 593 | + | x := r.X | |
| 594 | + | for i, child := range n.Children { | |
| 595 | + | w := base | |
| 596 | + | if i < rem { | |
| 597 | + | w++ | |
| 598 | + | } | |
| 599 | + | drawDividers(frame, child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, style) | |
| 600 | + | x += w | |
| 601 | + | if i < count-1 { | |
| 602 | + | for y := r.Y; y < r.Y+r.Height; y++ { | |
| 603 | + | frame.Set(x, y, renderer.Cell{Rune: '│', Style: style}) | |
| 604 | + | } | |
| 605 | + | x++ | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | return | |
| 609 | + | } | |
| 610 | + | usable := r.Height - (count - 1) | |
| 611 | + | base, rem := usable/count, usable%count | |
| 612 | + | y := r.Y | |
| 613 | + | for i, child := range n.Children { | |
| 614 | + | h := base | |
| 615 | + | if i < rem { | |
| 616 | + | h++ | |
| 617 | + | } | |
| 618 | + | drawDividers(frame, child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, style) | |
| 619 | + | y += h | |
| 620 | + | if i < count-1 { | |
| 621 | + | for x := r.X; x < r.X+r.Width; x++ { | |
| 622 | + | frame.Set(x, y, renderer.Cell{Rune: '─', Style: style}) | |
| 623 | + | } | |
| 624 | + | y++ | |
| 625 | + | } | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | ||
| 457 | 629 | func (c *barCache) capture(top, bottom []renderer.Cell) { | |
| 458 | 630 | c.top = cloneCells(c.top, top) | |
| 459 | 631 | c.bottom = cloneCells(c.bottom, bottom) | |
| @@ -482,11 +654,16 @@ func sameCells(a, b []renderer.Cell) bool { | |||
| 482 | 654 | } | |
| 483 | 655 | ||
| 484 | 656 | func offsetDamage(in []renderer.Damage) []renderer.Damage { | |
| 657 | + | return translateDamage(in, 0, 1) | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage { | |
| 485 | 661 | out := make([]renderer.Damage, len(in)) | |
| 486 | 662 | for i, d := range in { | |
| 487 | 663 | out[i] = d | |
| 488 | 664 | if d.Kind != renderer.DamageFullRedraw { | |
| 489 | - | out[i].Y++ | |
| 665 | + | out[i].X += dx | |
| 666 | + | out[i].Y += dy | |
| 490 | 667 | } | |
| 491 | 668 | } | |
| 492 | 669 | return out | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
| @@ -30,8 +29,11 @@ import ( | |||||
| 30 | "strconv" | 29 | "strconv" | ||
| 31 | "time" | 30 | "time" | ||
| 32 | 31 | ||||
| 32 | + | "github.com/bnema/vev/internal/domain" | |||
| 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | 33 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 34 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 34 | themeui "github.com/bnema/vev/internal/usecase/theme" | 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 36 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 35 | "github.com/bnema/vev/pkg/renderer" | 37 | "github.com/bnema/vev/pkg/renderer" | ||
| 36 | "github.com/bnema/vev/pkg/vt" | 38 | "github.com/bnema/vev/pkg/vt" | ||
| 37 | ) | 39 | ) | ||
| @@ -43,56 +45,65 @@ func signal(ch chan struct{}) { | |||||
| 43 | } | 45 | } | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | - | func (d *Daemon) ptyReader(sess *session, tb *tab) { | 48 | + | func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) { |
| 47 | defer d.sessWg.Done() | 49 | defer d.sessWg.Done() | ||
| 50 | + | var p *pane | |||
| 51 | + | if len(panes) > 0 { | |||
| 52 | + | p = panes[0] | |||
| 53 | + | } else { | |||
| 54 | + | p = tb.focusedPane() | |||
| 55 | + | } | |||
| 56 | + | if p == nil { | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 48 | buf := make([]byte, ptyReadBufSize) | 59 | buf := make([]byte, ptyReadBufSize) | ||
| 49 | var resp []byte | 60 | var resp []byte | ||
| 50 | attentionCh := make(chan struct{}, 1) | 61 | attentionCh := make(chan struct{}, 1) | ||
| 51 | - | tb.mu.Lock() | 62 | + | p.mu.Lock() |
| 52 | - | tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | 63 | + | p.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } |
| 53 | - | tb.screen.OnBell = func() { signal(attentionCh) } | 64 | + | p.screen.OnBell = func() { signal(attentionCh) } |
| 54 | - | tb.screen.OnNotify = func(string, string) { signal(attentionCh) } | 65 | + | p.screen.OnNotify = func(string, string) { signal(attentionCh) } |
| 55 | - | tb.mu.Unlock() | 66 | + | p.mu.Unlock() |
| 56 | for { | 67 | for { | ||
| 57 | - | n, err := tb.pty.Read(buf) | 68 | + | n, err := p.pty.Read(buf) |
| 58 | if n > 0 { | 69 | if n > 0 { | ||
| 59 | data := buf[:n] | 70 | data := buf[:n] | ||
| 60 | - | tb.mu.Lock() | 71 | + | p.mu.Lock() |
| 61 | - | wasSyncing := tb.screen.SyncUpdateActive() | 72 | + | wasSyncing := p.screen.SyncUpdateActive() |
| 62 | - | tb.screen.Write(data) | 73 | + | p.screen.Write(data) |
| 63 | - | isSyncing := tb.screen.SyncUpdateActive() | 74 | + | isSyncing := p.screen.SyncUpdateActive() |
| 64 | - | tb.mu.Unlock() | 75 | + | p.mu.Unlock() |
| 65 | select { | 76 | select { | ||
| 66 | case <-attentionCh: | 77 | case <-attentionCh: | ||
| 67 | d.noteAttention(sess, tb) | 78 | d.noteAttention(sess, tb) | ||
| 68 | default: | 79 | default: | ||
| 69 | } | 80 | } | ||
| 70 | if len(resp) > 0 { | 81 | if len(resp) > 0 { | ||
| 71 | - | if _, writeErr := tb.pty.Write(resp); writeErr != nil { | 82 | + | if _, writeErr := p.pty.Write(resp); writeErr != nil { |
| 72 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | 83 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | ||
| 73 | } | 84 | } | ||
| 74 | resp = resp[:0] | 85 | resp = resp[:0] | ||
| 75 | } | 86 | } | ||
| 76 | if wasSyncing != isSyncing { | 87 | if wasSyncing != isSyncing { | ||
| 77 | - | tb.mu.Lock() | 88 | + | p.mu.Lock() |
| 78 | - | tb.syncGen++ | 89 | + | p.syncGen++ |
| 79 | - | gen := tb.syncGen | 90 | + | gen := p.syncGen |
| 80 | - | tb.mu.Unlock() | 91 | + | p.mu.Unlock() |
| 81 | if isSyncing { | 92 | if isSyncing { | ||
| 82 | - | go d.syncWatchdog(tb, gen) | 93 | + | go d.syncWatchdog(p, gen) |
| 83 | } | 94 | } | ||
| 84 | } | 95 | } | ||
| 85 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | 96 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | ||
| 86 | - | signal(tb.flush) | 97 | + | signal(p.flush) |
| 87 | continue | 98 | continue | ||
| 88 | } | 99 | } | ||
| 89 | if isSyncing { | 100 | if isSyncing { | ||
| 90 | continue | 101 | continue | ||
| 91 | } | 102 | } | ||
| 92 | - | signal(tb.dirty) | 103 | + | signal(p.dirty) |
| 93 | } | 104 | } | ||
| 94 | if err != nil { | 105 | if err != nil { | ||
| 95 | - | d.closeTab(sess, tb, true) | 106 | + | d.reapPane(sess, tb, p) |
| 96 | return | 107 | return | ||
| 97 | } | 108 | } | ||
| 98 | } | 109 | } | ||
| @@ -101,8 +112,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) { | |||||
| 101 | // scheduler debounces dirty signals. The first dirty opens a short tab; | 112 | // scheduler debounces dirty signals. The first dirty opens a short tab; | ||
| 102 | // sustained floods progressively widen that tab, while isolated updates | 113 | // sustained floods progressively widen that tab, while isolated updates | ||
| 103 | // return to the minimum delay for interactive latency. | 114 | // return to the minimum delay for interactive latency. | ||
| 104 | - | func (d *Daemon) scheduler(sess *session, tb *tab) { | 115 | + | func (d *Daemon) scheduler(sess *session, tb *tab, panes ...*pane) { |
| 105 | defer d.sessWg.Done() | 116 | defer d.sessWg.Done() | ||
| 117 | + | var p *pane | |||
| 118 | + | if len(panes) > 0 { | |||
| 119 | + | p = panes[0] | |||
| 120 | + | } else { | |||
| 121 | + | p = tb.focusedPane() | |||
| 122 | + | } | |||
| 123 | + | if p == nil { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 106 | delay := minDebounceInterval | 126 | delay := minDebounceInterval | ||
| 107 | lastRender := d.clock.Now() | 127 | lastRender := d.clock.Now() | ||
| 108 | outer: | 128 | outer: | ||
| @@ -110,13 +130,13 @@ outer: | |||||
| 110 | select { | 130 | select { | ||
| 111 | case <-sess.ctx.Done(): | 131 | case <-sess.ctx.Done(): | ||
| 112 | return | 132 | return | ||
| 113 | - | case <-tabDone(tb): | 133 | + | case <-paneDone(p): |
| 114 | return | 134 | return | ||
| 115 | - | case <-tb.flush: | 135 | + | case <-p.flush: |
| 116 | d.render(sess, tb) | 136 | d.render(sess, tb) | ||
| 117 | lastRender = d.clock.Now() | 137 | lastRender = d.clock.Now() | ||
| 118 | continue | 138 | continue | ||
| 119 | - | case <-tb.dirty: | 139 | + | case <-p.dirty: |
| 120 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | 140 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | ||
| 121 | delay = minDebounceInterval | 141 | delay = minDebounceInterval | ||
| 122 | } | 142 | } | ||
| @@ -130,10 +150,10 @@ outer: | |||||
| 130 | case <-sess.ctx.Done(): | 150 | case <-sess.ctx.Done(): | ||
| 131 | timer.Stop() | 151 | timer.Stop() | ||
| 132 | return | 152 | return | ||
| 133 | - | case <-tabDone(tb): | 153 | + | case <-paneDone(p): |
| 134 | timer.Stop() | 154 | timer.Stop() | ||
| 135 | return | 155 | return | ||
| 136 | - | case <-tb.flush: | 156 | + | case <-p.flush: |
| 137 | if !timer.Stop() { | 157 | if !timer.Stop() { | ||
| 138 | select { | 158 | select { | ||
| 139 | case <-timer.C(): | 159 | case <-timer.C(): | ||
| @@ -143,7 +163,7 @@ outer: | |||||
| 143 | d.render(sess, tb) | 163 | d.render(sess, tb) | ||
| 144 | lastRender = d.clock.Now() | 164 | lastRender = d.clock.Now() | ||
| 145 | continue outer | 165 | continue outer | ||
| 146 | - | case <-tb.dirty: | 166 | + | case <-p.dirty: |
| 147 | coalesced++ | 167 | coalesced++ | ||
| 148 | case <-timer.C(): | 168 | case <-timer.C(): | ||
| 149 | break absorb | 169 | break absorb | ||
| @@ -169,30 +189,23 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||||
| 169 | return delay | 189 | return delay | ||
| 170 | } | 190 | } | ||
| 171 | 191 | ||||
| 172 | - | func (d *Daemon) syncWatchdog(tb *tab, gen uint64) { | 192 | + | func (d *Daemon) syncWatchdog(p *pane, gen uint64) { |
| 173 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | 193 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | ||
| 174 | select { | 194 | select { | ||
| 175 | - | case <-tabDone(tb): | 195 | + | case <-paneDone(p): |
| 176 | timer.Stop() | 196 | timer.Stop() | ||
| 177 | return | 197 | return | ||
| 178 | case <-timer.C(): | 198 | case <-timer.C(): | ||
| 179 | } | 199 | } | ||
| 180 | 200 | ||||
| 181 | - | tb.mu.Lock() | 201 | + | p.mu.Lock() |
| 182 | - | if tb.syncGen != gen || !tb.screen.SyncUpdateActive() { | 202 | + | if p.syncGen != gen || !p.screen.SyncUpdateActive() { |
| 183 | - | tb.mu.Unlock() | 203 | + | p.mu.Unlock() |
| 184 | return | 204 | return | ||
| 185 | } | 205 | } | ||
| 186 | - | tb.screen.ForceSyncEnd() | 206 | + | p.screen.ForceSyncEnd() |
| 187 | - | tb.mu.Unlock() | 207 | + | p.mu.Unlock() |
| 188 | - | signal(tb.flush) | 208 | + | signal(p.flush) |
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | func tabDone(tb *tab) <-chan struct{} { | |||
| 192 | - | if tb.ctx == nil { | |||
| 193 | - | return nil | |||
| 194 | - | } | |||
| 195 | - | return tb.ctx.Done() | |||
| 196 | } | 209 | } | ||
| 197 | 210 | ||||
| 198 | func syncUpdateEndIn(data []byte) bool { | 211 | func syncUpdateEndIn(data []byte) bool { | ||
| @@ -203,15 +216,18 @@ func syncUpdateEndIn(data []byte) bool { | |||||
| 203 | // damage so it never grows unbounded while headless. | 216 | // damage so it never grows unbounded while headless. | ||
| 204 | func (d *Daemon) render(sess *session, tb *tab) { | 217 | func (d *Daemon) render(sess *session, tb *tab) { | ||
| 205 | tb.mu.Lock() | 218 | tb.mu.Lock() | ||
| 206 | - | if tb.screen.SyncUpdateActive() { | 219 | + | p := tb.focusedPane() |
| 207 | - | tb.mu.Unlock() | |||
| 208 | - | return | |||
| 209 | - | } | |||
| 210 | - | tb.mu.Unlock() | |||
| 211 | - | ||||
| 212 | - | tb.mu.Lock() | |||
| 213 | previewer := tb.previewClient | 220 | previewer := tb.previewClient | ||
| 214 | tb.mu.Unlock() | 221 | tb.mu.Unlock() | ||
| 222 | + | if p == nil { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | p.mu.Lock() | |||
| 226 | + | if p.screen.SyncUpdateActive() { | |||
| 227 | + | p.mu.Unlock() | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | p.mu.Unlock() | |||
| 215 | 231 | ||||
| 216 | sess.mu.Lock() | 232 | sess.mu.Lock() | ||
| 217 | ac := sess.client | 233 | ac := sess.client | ||
| @@ -226,16 +242,16 @@ func (d *Daemon) render(sess *session, tb *tab) { | |||||
| 226 | d.paint(sess, ac, false) | 242 | d.paint(sess, ac, false) | ||
| 227 | return | 243 | return | ||
| 228 | } | 244 | } | ||
| 229 | - | tb.mu.Lock() | 245 | + | p.mu.Lock() |
| 230 | - | tb.screen.ClearDamage() | 246 | + | p.screen.ClearDamage() |
| 231 | - | tb.mu.Unlock() | 247 | + | p.mu.Unlock() |
| 232 | return | 248 | return | ||
| 233 | } | 249 | } | ||
| 234 | 250 | ||||
| 235 | if ac == nil || !active { | 251 | if ac == nil || !active { | ||
| 236 | - | tb.mu.Lock() | 252 | + | p.mu.Lock() |
| 237 | - | tb.screen.ClearDamage() | 253 | + | p.screen.ClearDamage() |
| 238 | - | tb.mu.Unlock() | 254 | + | p.mu.Unlock() |
| 239 | return | 255 | return | ||
| 240 | } | 256 | } | ||
| 241 | d.paint(sess, ac, false) | 257 | d.paint(sess, ac, false) | ||
| @@ -288,8 +304,27 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 288 | bars.theme = ac.getTheme() | 304 | bars.theme = ac.getTheme() | ||
| 289 | 305 | ||||
| 290 | styles := newThemeStyles(ac.getTheme()) | 306 | styles := newThemeStyles(ac.getTheme()) | ||
| 307 | + | tb.mu.Lock() | |||
| 308 | + | var titleIDs []layout.PaneID | |||
| 309 | + | if placements, ok := solvedPlacementsLocked(tb); ok { | |||
| 310 | + | for _, pl := range placements { | |||
| 311 | + | if pl.TitleBar.Height > 0 { | |||
| 312 | + | titleIDs = append(titleIDs, pl.ID) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | tb.mu.Unlock() | |||
| 317 | + | for _, id := range titleIDs { | |||
| 318 | + | d.refreshPaneTitle(sess, id) | |||
| 319 | + | } | |||
| 291 | 320 | ||||
| 292 | tb.mu.Lock() | 321 | tb.mu.Lock() | ||
| 322 | + | p := tb.focusedPane() | |||
| 323 | + | if p == nil { | |||
| 324 | + | tb.mu.Unlock() | |||
| 325 | + | ac.sendMu.Unlock() | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 293 | if reset || copyActive || pickerActive || paletteActive || promptActive { | 328 | if reset || copyActive || pickerActive || paletteActive || promptActive { | ||
| 294 | ac.rend.Reset() | 329 | ac.rend.Reset() | ||
| 295 | ac.bars.Reset() | 330 | ac.bars.Reset() | ||
| @@ -303,7 +338,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 303 | } | 338 | } | ||
| 304 | if pickerActive { | 339 | if pickerActive { | ||
| 305 | if previewTab == tb { | 340 | if previewTab == tb { | ||
| 306 | - | preview = pickerPreviewFromLockedTab(tb) | 341 | + | p.mu.Lock() |
| 342 | + | preview = pickerPreviewFromLockedPane(p) | |||
| 343 | + | p.mu.Unlock() | |||
| 307 | } | 344 | } | ||
| 308 | frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles) | 345 | frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles) | ||
| 309 | } | 346 | } | ||
| @@ -315,13 +352,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 315 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | 352 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | ||
| 316 | ac.promptMu.Unlock() | 353 | ac.promptMu.Unlock() | ||
| 317 | } | 354 | } | ||
| 318 | - | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive) | 355 | + | p.mu.Lock() |
| 356 | + | desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive) | |||
| 357 | + | p.mu.Unlock() | |||
| 319 | data, err := ac.rend.Draw(frame, damage) | 358 | data, err := ac.rend.Draw(frame, damage) | ||
| 320 | var cursorTail []byte | 359 | var cursorTail []byte | ||
| 321 | if err == nil { | 360 | if err == nil { | ||
| 322 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | 361 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | ||
| 323 | } | 362 | } | ||
| 324 | - | tb.screen.ClearDamage() | 363 | + | for _, pane := range tb.panesSnapshot() { |
| 364 | + | pane.mu.Lock() | |||
| 365 | + | pane.screen.ClearDamage() | |||
| 366 | + | pane.mu.Unlock() | |||
| 367 | + | } | |||
| 325 | tb.mu.Unlock() | 368 | tb.mu.Unlock() | ||
| 326 | 369 | ||||
| 327 | var serr error | 370 | var serr error | ||
| @@ -425,13 +468,18 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||||
| 425 | if len(caches) > 0 { | 468 | if len(caches) > 0 { | ||
| 426 | cache = caches[0] | 469 | cache = caches[0] | ||
| 427 | } | 470 | } | ||
| 471 | + | p := tb.focusedPane() | |||
| 428 | styles := newThemeStyles(bars.theme) | 472 | styles := newThemeStyles(bars.theme) | ||
| 429 | - | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | 473 | + | width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height |
| 474 | + | if tb.size.Valid() { | |||
| 475 | + | width, screenRows = tb.size.Cols, tb.size.Rows | |||
| 476 | + | } | |||
| 430 | frame := renderer.NewFrame(width, screenRows+2) | 477 | frame := renderer.NewFrame(width, screenRows+2) | ||
| 431 | topBar := frame.Row(0) | 478 | topBar := frame.Row(0) | ||
| 432 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles) | 479 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles) | ||
| 480 | + | contentFrame, contentDamage := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |||
| 433 | for y := range screenRows { | 481 | for y := range screenRows { | ||
| 434 | - | copy(frame.Row(y+1), tb.screen.Frame.Row(y)) | 482 | + | copy(frame.Row(y+1), contentFrame.Row(y)) |
| 435 | } | 483 | } | ||
| 436 | bottomBar := frame.Row(screenRows + 1) | 484 | bottomBar := frame.Row(screenRows + 1) | ||
| 437 | drawStatusBarState(bottomBar, bars, styles) | 485 | drawStatusBarState(bottomBar, bars, styles) | ||
| @@ -441,7 +489,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||||
| 441 | } | 489 | } | ||
| 442 | return frame, []renderer.Damage{renderer.FullRedraw()} | 490 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 443 | } | 491 | } | ||
| 444 | - | damage := offsetDamage(tb.screen.Damage()) | 492 | + | damage := translateDamage(contentDamage, 0, 1) |
| 445 | if cache == nil || !sameCells(cache.top, topBar) { | 493 | if cache == nil || !sameCells(cache.top, topBar) { | ||
| 446 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 494 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 447 | } | 495 | } | ||
| @@ -454,6 +502,130 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b | |||||
| 454 | return frame, damage | 502 | return frame, damage | ||
| 455 | } | 503 | } | ||
| 456 | 504 | ||||
| 505 | + | func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) { | |||
| 506 | + | frame := renderer.NewFrame(area.Width, area.Height) | |||
| 507 | + | var root *layout.Node | |||
| 508 | + | if tb.tree != nil { | |||
| 509 | + | root = tb.tree.Root | |||
| 510 | + | } | |||
| 511 | + | placements, ok := layout.Solve(root, area) | |||
| 512 | + | var fallback *pane | |||
| 513 | + | if !ok { | |||
| 514 | + | fallback = tb.focusedPane() | |||
| 515 | + | if fallback == nil { | |||
| 516 | + | return frame, nil | |||
| 517 | + | } | |||
| 518 | + | placements = []layout.Placement{{ID: fallback.id, Content: area}} | |||
| 519 | + | } | |||
| 520 | + | drawDividers(frame, root, area, newThemeStyles(theme).border) | |||
| 521 | + | var damage []renderer.Damage | |||
| 522 | + | for _, pl := range placements { | |||
| 523 | + | p := tb.panes[pl.ID] | |||
| 524 | + | if len(tb.panes) == 1 && tb.tree != nil && tb.tree.Focus == pl.ID { | |||
| 525 | + | p = tb.focusedPane() | |||
| 526 | + | } | |||
| 527 | + | if p == nil && fallback != nil && pl.ID == fallback.id { | |||
| 528 | + | p = fallback | |||
| 529 | + | } | |||
| 530 | + | if p == nil { | |||
| 531 | + | continue | |||
| 532 | + | } | |||
| 533 | + | focused := tb.tree == nil || tb.tree.Focus == pl.ID | |||
| 534 | + | if pl.TitleBar.Height > 0 { | |||
| 535 | + | drawPaneTitleBar(frame, pl, p, focused, theme, "") | |||
| 536 | + | } | |||
| 537 | + | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |||
| 538 | + | continue | |||
| 539 | + | } | |||
| 540 | + | p.mu.Lock() | |||
| 541 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |||
| 542 | + | for _, d := range p.screen.Damage() { | |||
| 543 | + | damage = append(damage, translateDamage([]renderer.Damage{d}, pl.Content.X, pl.Content.Y)...) | |||
| 544 | + | } | |||
| 545 | + | p.mu.Unlock() | |||
| 546 | + | } | |||
| 547 | + | return frame, damage | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | |||
| 551 | + | rows := min(r.Height, src.Height) | |||
| 552 | + | cols := min(r.Width, src.Width) | |||
| 553 | + | for y := range rows { | |||
| 554 | + | for x := range cols { | |||
| 555 | + | cell := src.At(x, y) | |||
| 556 | + | if dim { | |||
| 557 | + | cell.Style = themeui.DimStyle(cell.Style, theme) | |||
| 558 | + | } | |||
| 559 | + | dst.Set(r.X+x, r.Y+y, cell) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) { | |||
| 565 | + | styles := newThemeStyles(theme) | |||
| 566 | + | style := styles.border | |||
| 567 | + | if focused { | |||
| 568 | + | style = styles.accent | |||
| 569 | + | } else { | |||
| 570 | + | style = themeui.DimStyle(style, theme) | |||
| 571 | + | } | |||
| 572 | + | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |||
| 573 | + | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |||
| 574 | + | } | |||
| 575 | + | title := p.title | |||
| 576 | + | if title == "" { | |||
| 577 | + | title = fallback | |||
| 578 | + | } | |||
| 579 | + | if title == "" { | |||
| 580 | + | title = string(p.id) | |||
| 581 | + | } | |||
| 582 | + | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | |||
| 586 | + | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | |||
| 587 | + | return | |||
| 588 | + | } | |||
| 589 | + | count := len(n.Children) | |||
| 590 | + | if n.Dir == layout.Horizontal { | |||
| 591 | + | usable := r.Width - (count - 1) | |||
| 592 | + | base, rem := usable/count, usable%count | |||
| 593 | + | x := r.X | |||
| 594 | + | for i, child := range n.Children { | |||
| 595 | + | w := base | |||
| 596 | + | if i < rem { | |||
| 597 | + | w++ | |||
| 598 | + | } | |||
| 599 | + | drawDividers(frame, child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, style) | |||
| 600 | + | x += w | |||
| 601 | + | if i < count-1 { | |||
| 602 | + | for y := r.Y; y < r.Y+r.Height; y++ { | |||
| 603 | + | frame.Set(x, y, renderer.Cell{Rune: '│', Style: style}) | |||
| 604 | + | } | |||
| 605 | + | x++ | |||
| 606 | + | } | |||
| 607 | + | } | |||
| 608 | + | return | |||
| 609 | + | } | |||
| 610 | + | usable := r.Height - (count - 1) | |||
| 611 | + | base, rem := usable/count, usable%count | |||
| 612 | + | y := r.Y | |||
| 613 | + | for i, child := range n.Children { | |||
| 614 | + | h := base | |||
| 615 | + | if i < rem { | |||
| 616 | + | h++ | |||
| 617 | + | } | |||
| 618 | + | drawDividers(frame, child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, style) | |||
| 619 | + | y += h | |||
| 620 | + | if i < count-1 { | |||
| 621 | + | for x := r.X; x < r.X+r.Width; x++ { | |||
| 622 | + | frame.Set(x, y, renderer.Cell{Rune: '─', Style: style}) | |||
| 623 | + | } | |||
| 624 | + | y++ | |||
| 625 | + | } | |||
| 626 | + | } | |||
| 627 | + | } | |||
| 628 | + | ||||
| 457 | func (c *barCache) capture(top, bottom []renderer.Cell) { | 629 | func (c *barCache) capture(top, bottom []renderer.Cell) { | ||
| 458 | c.top = cloneCells(c.top, top) | 630 | c.top = cloneCells(c.top, top) | ||
| 459 | c.bottom = cloneCells(c.bottom, bottom) | 631 | c.bottom = cloneCells(c.bottom, bottom) | ||
| @@ -482,11 +654,16 @@ func sameCells(a, b []renderer.Cell) bool { | |||||
| 482 | } | 654 | } | ||
| 483 | 655 | ||||
| 484 | func offsetDamage(in []renderer.Damage) []renderer.Damage { | 656 | func offsetDamage(in []renderer.Damage) []renderer.Damage { | ||
| 657 | + | return translateDamage(in, 0, 1) | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage { | |||
| 485 | out := make([]renderer.Damage, len(in)) | 661 | out := make([]renderer.Damage, len(in)) | ||
| 486 | for i, d := range in { | 662 | for i, d := range in { | ||
| 487 | out[i] = d | 663 | out[i] = d | ||
| 488 | if d.Kind != renderer.DamageFullRedraw { | 664 | if d.Kind != renderer.DamageFullRedraw { | ||
| 489 | - | out[i].Y++ | 665 | + | out[i].X += dx |
| 666 | + | out[i].Y += dy | |||
| 490 | } | 667 | } | ||
| 491 | } | 668 | } | ||
| 492 | return out | 669 | return out | ||
M internal/usecase/daemon/render_test.go +80 -13
| @@ -17,6 +17,8 @@ import ( | |||
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | 18 | "github.com/bnema/vev/internal/ports" | |
| 19 | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 20 | 22 | "github.com/bnema/vev/pkg/renderer" | |
| 21 | 23 | "github.com/bnema/vev/pkg/vt" | |
| 22 | 24 | ) | |
| @@ -323,6 +325,68 @@ func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) { | |||
| 323 | 325 | }, damage) | |
| 324 | 326 | } | |
| 325 | 327 | ||
| 328 | + | func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) { | |
| 329 | + | damage := translateDamage([]renderer.Damage{ | |
| 330 | + | {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1}, | |
| 331 | + | renderer.FullRedraw(), | |
| 332 | + | }, 5, 7) | |
| 333 | + | require.Equal(t, []renderer.Damage{ | |
| 334 | + | {Kind: renderer.DamageText, X: 7, Y: 10, Width: 4, Height: 1}, | |
| 335 | + | renderer.FullRedraw(), | |
| 336 | + | }, damage) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |
| 340 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 341 | + | left := win.focusedPane() | |
| 342 | + | left.screen.ClearDamage() | |
| 343 | + | left.screen.Write([]byte("L")) | |
| 344 | + | left.screen.ClearDamage() | |
| 345 | + | ||
| 346 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |
| 347 | + | right.screen.Write([]byte("R")) | |
| 348 | + | right.screen.ClearDamage() | |
| 349 | + | right.screen.Write([]byte("x")) | |
| 350 | + | ||
| 351 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 352 | + | win.tree.Focus = right.id | |
| 353 | + | win.panes[right.id] = right | |
| 354 | + | ||
| 355 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 356 | + | frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme) | |
| 357 | + | ||
| 358 | + | require.Equal(t, 'L', frame.At(0, 0).Rune) | |
| 359 | + | require.Equal(t, '│', frame.At(20, 0).Rune) | |
| 360 | + | require.Equal(t, 'R', frame.At(21, 0).Rune) | |
| 361 | + | require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit") | |
| 362 | + | require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen") | |
| 363 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1}) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 367 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 368 | + | p1 := win.focusedPane() | |
| 369 | + | p1.title = "one" | |
| 370 | + | p1.screen.ClearDamage() | |
| 371 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |
| 372 | + | p2.title = "two" | |
| 373 | + | p2.screen.Write([]byte("T")) | |
| 374 | + | p2.screen.ClearDamage() | |
| 375 | + | ||
| 376 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |
| 377 | + | win.tree.Focus = p2.id | |
| 378 | + | win.panes[p2.id] = p2 | |
| 379 | + | ||
| 380 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 381 | + | frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme) | |
| 382 | + | ||
| 383 | + | require.Equal(t, "one", rowText(frame.Row(0))[:3]) | |
| 384 | + | require.Equal(t, "two", rowText(frame.Row(1))[:3]) | |
| 385 | + | require.Equal(t, 'T', frame.At(0, 2).Rune) | |
| 386 | + | require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome") | |
| 387 | + | require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome") | |
| 388 | + | } | |
| 389 | + | ||
| 326 | 390 | func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) { | |
| 327 | 391 | sess, win := newBarCacheTestSession() | |
| 328 | 392 | var cache barCache | |
| @@ -463,22 +527,25 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 463 | 527 | ||
| 464 | 528 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 465 | 529 | sctx, cancel := context.WithCancel(context.Background()) | |
| 466 | - | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 530 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 531 | + | win.ctx, win.cancel = sctx, cancel | |
| 532 | + | p := win.focusedPane() | |
| 533 | + | p.ctx, p.cancel = sctx, cancel | |
| 467 | 534 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 468 | 535 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 469 | 536 | ac.setSession(sess) | |
| 470 | 537 | ||
| 471 | - | win.mu.Lock() | |
| 472 | - | win.screen.Write([]byte("before")) | |
| 473 | - | win.mu.Unlock() | |
| 538 | + | p.mu.Lock() | |
| 539 | + | p.screen.Write([]byte("before")) | |
| 540 | + | p.mu.Unlock() | |
| 474 | 541 | ||
| 475 | 542 | d.sessWg.Add(1) | |
| 476 | - | go d.scheduler(sess, win) | |
| 477 | - | win.dirty <- struct{}{} | |
| 543 | + | go d.scheduler(sess, win, p) | |
| 544 | + | p.dirty <- struct{}{} | |
| 478 | 545 | ||
| 479 | - | win.mu.Lock() | |
| 480 | - | win.screen.Write([]byte("\x1b[?2026hafter")) | |
| 481 | - | win.mu.Unlock() | |
| 546 | + | p.mu.Lock() | |
| 547 | + | p.screen.Write([]byte("\x1b[?2026hafter")) | |
| 548 | + | p.mu.Unlock() | |
| 482 | 549 | timerCh <- time.Now() | |
| 483 | 550 | ||
| 484 | 551 | select { | |
| @@ -488,10 +555,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 488 | 555 | } | |
| 489 | 556 | require.Equal(t, int32(0), outputs.Load()) | |
| 490 | 557 | ||
| 491 | - | win.mu.Lock() | |
| 492 | - | win.screen.Write([]byte(" done\x1b[?2026l")) | |
| 493 | - | win.mu.Unlock() | |
| 494 | - | win.flush <- struct{}{} | |
| 558 | + | p.mu.Lock() | |
| 559 | + | p.screen.Write([]byte(" done\x1b[?2026l")) | |
| 560 | + | p.mu.Unlock() | |
| 561 | + | p.flush <- struct{}{} | |
| 495 | 562 | select { | |
| 496 | 563 | case <-gotOutput: | |
| 497 | 564 | case <-time.After(time.Second): | |
| @@ -17,6 +17,8 @@ import ( | |||||
| 17 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 18 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 20 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 21 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 20 | "github.com/bnema/vev/pkg/renderer" | 22 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | "github.com/bnema/vev/pkg/vt" | 23 | "github.com/bnema/vev/pkg/vt" | ||
| 22 | ) | 24 | ) | ||
| @@ -323,6 +325,68 @@ func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) { | |||||
| 323 | }, damage) | 325 | }, damage) | ||
| 324 | } | 326 | } | ||
| 325 | 327 | ||||
| 328 | + | func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) { | |||
| 329 | + | damage := translateDamage([]renderer.Damage{ | |||
| 330 | + | {Kind: renderer.DamageText, X: 2, Y: 3, Width: 4, Height: 1}, | |||
| 331 | + | renderer.FullRedraw(), | |||
| 332 | + | }, 5, 7) | |||
| 333 | + | require.Equal(t, []renderer.Damage{ | |||
| 334 | + | {Kind: renderer.DamageText, X: 7, Y: 10, Width: 4, Height: 1}, | |||
| 335 | + | renderer.FullRedraw(), | |||
| 336 | + | }, damage) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |||
| 340 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |||
| 341 | + | left := win.focusedPane() | |||
| 342 | + | left.screen.ClearDamage() | |||
| 343 | + | left.screen.Write([]byte("L")) | |||
| 344 | + | left.screen.ClearDamage() | |||
| 345 | + | ||||
| 346 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |||
| 347 | + | right.screen.Write([]byte("R")) | |||
| 348 | + | right.screen.ClearDamage() | |||
| 349 | + | right.screen.Write([]byte("x")) | |||
| 350 | + | ||||
| 351 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 352 | + | win.tree.Focus = right.id | |||
| 353 | + | win.panes[right.id] = right | |||
| 354 | + | ||||
| 355 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |||
| 356 | + | frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme) | |||
| 357 | + | ||||
| 358 | + | require.Equal(t, 'L', frame.At(0, 0).Rune) | |||
| 359 | + | require.Equal(t, '│', frame.At(20, 0).Rune) | |||
| 360 | + | require.Equal(t, 'R', frame.At(21, 0).Rune) | |||
| 361 | + | require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit") | |||
| 362 | + | require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen") | |||
| 363 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1}) | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 367 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 368 | + | p1 := win.focusedPane() | |||
| 369 | + | p1.title = "one" | |||
| 370 | + | p1.screen.ClearDamage() | |||
| 371 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |||
| 372 | + | p2.title = "two" | |||
| 373 | + | p2.screen.Write([]byte("T")) | |||
| 374 | + | p2.screen.ClearDamage() | |||
| 375 | + | ||||
| 376 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |||
| 377 | + | win.tree.Focus = p2.id | |||
| 378 | + | win.panes[p2.id] = p2 | |||
| 379 | + | ||||
| 380 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |||
| 381 | + | frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme) | |||
| 382 | + | ||||
| 383 | + | require.Equal(t, "one", rowText(frame.Row(0))[:3]) | |||
| 384 | + | require.Equal(t, "two", rowText(frame.Row(1))[:3]) | |||
| 385 | + | require.Equal(t, 'T', frame.At(0, 2).Rune) | |||
| 386 | + | require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome") | |||
| 387 | + | require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome") | |||
| 388 | + | } | |||
| 389 | + | ||||
| 326 | func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) { | 390 | func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) { | ||
| 327 | sess, win := newBarCacheTestSession() | 391 | sess, win := newBarCacheTestSession() | ||
| 328 | var cache barCache | 392 | var cache barCache | ||
| @@ -463,22 +527,25 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 463 | 527 | ||||
| 464 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | 528 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 465 | sctx, cancel := context.WithCancel(context.Background()) | 529 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 466 | - | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 530 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) |
| 531 | + | win.ctx, win.cancel = sctx, cancel | |||
| 532 | + | p := win.focusedPane() | |||
| 533 | + | p.ctx, p.cancel = sctx, cancel | |||
| 467 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 534 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | ||
| 468 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 535 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 469 | ac.setSession(sess) | 536 | ac.setSession(sess) | ||
| 470 | 537 | ||||
| 471 | - | win.mu.Lock() | 538 | + | p.mu.Lock() |
| 472 | - | win.screen.Write([]byte("before")) | 539 | + | p.screen.Write([]byte("before")) |
| 473 | - | win.mu.Unlock() | 540 | + | p.mu.Unlock() |
| 474 | 541 | ||||
| 475 | d.sessWg.Add(1) | 542 | d.sessWg.Add(1) | ||
| 476 | - | go d.scheduler(sess, win) | 543 | + | go d.scheduler(sess, win, p) |
| 477 | - | win.dirty <- struct{}{} | 544 | + | p.dirty <- struct{}{} |
| 478 | 545 | ||||
| 479 | - | win.mu.Lock() | 546 | + | p.mu.Lock() |
| 480 | - | win.screen.Write([]byte("\x1b[?2026hafter")) | 547 | + | p.screen.Write([]byte("\x1b[?2026hafter")) |
| 481 | - | win.mu.Unlock() | 548 | + | p.mu.Unlock() |
| 482 | timerCh <- time.Now() | 549 | timerCh <- time.Now() | ||
| 483 | 550 | ||||
| 484 | select { | 551 | select { | ||
| @@ -488,10 +555,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 488 | } | 555 | } | ||
| 489 | require.Equal(t, int32(0), outputs.Load()) | 556 | require.Equal(t, int32(0), outputs.Load()) | ||
| 490 | 557 | ||||
| 491 | - | win.mu.Lock() | 558 | + | p.mu.Lock() |
| 492 | - | win.screen.Write([]byte(" done\x1b[?2026l")) | 559 | + | p.screen.Write([]byte(" done\x1b[?2026l")) |
| 493 | - | win.mu.Unlock() | 560 | + | p.mu.Unlock() |
| 494 | - | win.flush <- struct{}{} | 561 | + | p.flush <- struct{}{} |
| 495 | select { | 562 | select { | ||
| 496 | case <-gotOutput: | 563 | case <-gotOutput: | ||
| 497 | case <-time.After(time.Second): | 564 | case <-time.After(time.Second): | ||
M internal/usecase/daemon/session.go +109 -26
| @@ -17,11 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // sendMu > Daemon.mu > session.mu > tab.mu — the PTY reader only ever takes | |
| 24 | - | // tab.mu, so it never blocks on a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 25 | 25 | package daemon | |
| 26 | 26 | ||
| 27 | 27 | import ( | |
| @@ -37,6 +37,7 @@ import ( | |||
| 37 | 37 | "github.com/bnema/vev/internal/persist" | |
| 38 | 38 | "github.com/bnema/vev/internal/ports" | |
| 39 | 39 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 40 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 40 | 41 | "github.com/bnema/vev/pkg/vt" | |
| 41 | 42 | ) | |
| 42 | 43 | ||
| @@ -56,26 +57,33 @@ type session struct { | |||
| 56 | 57 | createdAt int64 | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | - | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | |
| 60 | - | // applies back-pressure by collapsing (a full buffer means "a render is | |
| 61 | - | // already pending"), never by blocking the reader. | |
| 60 | + | // tab is a pane layout container. In the single-leaf case it preserves the old | |
| 61 | + | // one-PTY tab behavior; pane owns PTY/screen/scrollback/render scheduling state. | |
| 62 | 62 | type tab struct { | |
| 63 | + | // Compatibility aliases for the single focused pane. Production call sites use | |
| 64 | + | // focusedPane/all-pane loops; tests still inspect the single-leaf state directly. | |
| 63 | 65 | pty ports.PTY | |
| 64 | - | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | |
| 65 | 66 | screen *vt.Screen | |
| 66 | 67 | scrollback *scopy.Scrollback | |
| 67 | 68 | dirty chan struct{} | |
| 68 | 69 | flush chan struct{} | |
| 69 | 70 | syncGen uint64 | |
| 71 | + | ||
| 72 | + | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | |
| 73 | + | ||
| 74 | + | tree *layout.Tree | |
| 75 | + | panes map[layout.PaneID]*pane | |
| 76 | + | nextPaneID int | |
| 77 | + | size domain.Size | |
| 78 | + | ctx context.Context | |
| 79 | + | cancel context.CancelFunc | |
| 80 | + | ||
| 70 | 81 | // previewClient tracks the one client currently previewing this tab in the picker. | |
| 71 | 82 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | |
| 72 | 83 | previewClient *attachedClient | |
| 73 | 84 | // attention and attentionAt are guarded by the owning session.mu. | |
| 74 | 85 | attention bool | |
| 75 | 86 | attentionAt time.Time | |
| 76 | - | size domain.Size | |
| 77 | - | ctx context.Context | |
| 78 | - | cancel context.CancelFunc | |
| 79 | 87 | } | |
| 80 | 88 | ||
| 81 | 89 | // attachedClient is a client currently attached to a session's tab. rend is | |
| @@ -183,7 +191,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 183 | 191 | tb := newTab(pty, tbSize) | |
| 184 | 192 | if client != nil { | |
| 185 | 193 | t := client.getTheme() | |
| 186 | - | tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |
| 194 | + | tb.mu.Lock() | |
| 195 | + | p := tb.focusedPane() | |
| 196 | + | tb.mu.Unlock() | |
| 197 | + | if p != nil { | |
| 198 | + | p.mu.Lock() | |
| 199 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |
| 200 | + | p.mu.Unlock() | |
| 201 | + | } | |
| 187 | 202 | } | |
| 188 | 203 | d.mu.Lock() | |
| 189 | 204 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | |
| @@ -202,19 +217,56 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 202 | 217 | } | |
| 203 | 218 | ||
| 204 | 219 | func newTab(pty ports.PTY, sz domain.Size) *tab { | |
| 205 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 206 | - | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 207 | - | screen.OnLineEvicted = sb.Append | |
| 220 | + | id := layout.PaneID("pane-1") | |
| 221 | + | p := newPane(id, pty, sz) | |
| 208 | 222 | return &tab{ | |
| 209 | - | pty: pty, | |
| 210 | - | screen: screen, | |
| 211 | - | scrollback: sb, | |
| 212 | - | dirty: make(chan struct{}, 1), | |
| 213 | - | flush: make(chan struct{}, 1), | |
| 223 | + | pty: p.pty, | |
| 224 | + | screen: p.screen, | |
| 225 | + | scrollback: p.scrollback, | |
| 226 | + | dirty: p.dirty, | |
| 227 | + | flush: p.flush, | |
| 228 | + | tree: layout.NewTree(id), | |
| 229 | + | panes: map[layout.PaneID]*pane{id: p}, | |
| 230 | + | nextPaneID: 2, | |
| 214 | 231 | size: sz, | |
| 215 | 232 | } | |
| 216 | 233 | } | |
| 217 | 234 | ||
| 235 | + | func (tb *tab) focusedPane() *pane { | |
| 236 | + | if tb == nil { | |
| 237 | + | return nil | |
| 238 | + | } | |
| 239 | + | if tb.tree != nil && tb.panes != nil { | |
| 240 | + | p := tb.panes[tb.tree.Focus] | |
| 241 | + | if p == nil { | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | if len(tb.panes) == 1 && ((tb.pty != nil && tb.pty != p.pty) || (tb.screen != nil && tb.screen != p.screen) || (tb.scrollback != nil && tb.scrollback != p.scrollback) || (tb.dirty != nil && tb.dirty != p.dirty) || (tb.flush != nil && tb.flush != p.flush)) { | |
| 245 | + | return &pane{id: p.id, pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, rect: p.rect, ctx: tb.ctx, cancel: tb.cancel} | |
| 246 | + | } | |
| 247 | + | return p | |
| 248 | + | } | |
| 249 | + | if tb.pty != nil || tb.screen != nil || tb.scrollback != nil { | |
| 250 | + | return &pane{pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, ctx: tb.ctx, cancel: tb.cancel} | |
| 251 | + | } | |
| 252 | + | return nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (tb *tab) panesSnapshot() []*pane { | |
| 256 | + | if tb == nil { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | p0 := tb.focusedPane() | |
| 260 | + | if len(tb.panes) == 0 && p0 != nil { | |
| 261 | + | return []*pane{p0} | |
| 262 | + | } | |
| 263 | + | out := make([]*pane, 0, len(tb.panes)) | |
| 264 | + | for _, p := range tb.panes { | |
| 265 | + | out = append(out, p) | |
| 266 | + | } | |
| 267 | + | return out | |
| 268 | + | } | |
| 269 | + | ||
| 218 | 270 | func tabSize(clientSize domain.Size) domain.Size { | |
| 219 | 271 | if !clientSize.Valid() { | |
| 220 | 272 | clientSize = defaultSize | |
| @@ -224,9 +276,18 @@ func tabSize(clientSize domain.Size) domain.Size { | |||
| 224 | 276 | } | |
| 225 | 277 | ||
| 226 | 278 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |
| 279 | + | tb.mu.Lock() | |
| 280 | + | panes := tb.panesSnapshot() | |
| 281 | + | tb.mu.Unlock() | |
| 282 | + | for _, p := range panes { | |
| 283 | + | d.startPaneGoroutines(sess, tb, p) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |
| 227 | 288 | d.sessWg.Add(2) | |
| 228 | - | go d.ptyReader(sess, tb) | |
| 229 | - | go d.scheduler(sess, tb) | |
| 289 | + | go d.ptyReader(sess, tb, p) | |
| 290 | + | go d.scheduler(sess, tb, p) | |
| 230 | 291 | } | |
| 231 | 292 | ||
| 232 | 293 | // attachClient makes ac the session's current client, displacing any prior one | |
| @@ -345,7 +406,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 345 | 406 | if tb.cancel != nil { | |
| 346 | 407 | tb.cancel() | |
| 347 | 408 | } | |
| 348 | - | _ = tb.pty.Close() | |
| 409 | + | tb.mu.Lock() | |
| 410 | + | panes := tb.panesSnapshot() | |
| 411 | + | tb.mu.Unlock() | |
| 412 | + | for _, p := range panes { | |
| 413 | + | if p.cancel != nil { | |
| 414 | + | p.cancel() | |
| 415 | + | } | |
| 416 | + | _ = p.pty.Close() | |
| 417 | + | } | |
| 349 | 418 | if repaint && ac != nil { | |
| 350 | 419 | d.paint(sess, ac, true) | |
| 351 | 420 | } | |
| @@ -425,7 +494,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 425 | 494 | sess.mu.Unlock() | |
| 426 | 495 | for _, tb := range tabs { | |
| 427 | 496 | d.clearDestroyedTabPreview(tb) | |
| 428 | - | _ = tb.pty.Close() | |
| 497 | + | tb.mu.Lock() | |
| 498 | + | panes := tb.panesSnapshot() | |
| 499 | + | tb.mu.Unlock() | |
| 500 | + | for _, p := range panes { | |
| 501 | + | if p.cancel != nil { | |
| 502 | + | p.cancel() | |
| 503 | + | } | |
| 504 | + | _ = p.pty.Close() | |
| 505 | + | } | |
| 429 | 506 | } | |
| 430 | 507 | if empty { | |
| 431 | 508 | d.doneOnce.Do(func() { close(d.done) }) | |
| @@ -509,7 +586,13 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 509 | 586 | if tb == nil { | |
| 510 | 587 | return | |
| 511 | 588 | } | |
| 512 | - | cwd, err := d.procCwd(tb.pty.Pid()) | |
| 589 | + | tb.mu.Lock() | |
| 590 | + | p := tb.focusedPane() | |
| 591 | + | tb.mu.Unlock() | |
| 592 | + | if p == nil { | |
| 593 | + | return | |
| 594 | + | } | |
| 595 | + | cwd, err := d.procCwd(p.pty.Pid()) | |
| 513 | 596 | if err != nil || cwd == "" { | |
| 514 | 597 | return | |
| 515 | 598 | } | |
| @@ -17,11 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // sendMu > Daemon.mu > session.mu > tab.mu — the PTY reader only ever takes | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu, so it never blocks on a slow client. | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | package daemon | 25 | package daemon | ||
| 26 | 26 | ||||
| 27 | import ( | 27 | import ( | ||
| @@ -37,6 +37,7 @@ import ( | |||||
| 37 | "github.com/bnema/vev/internal/persist" | 37 | "github.com/bnema/vev/internal/persist" | ||
| 38 | "github.com/bnema/vev/internal/ports" | 38 | "github.com/bnema/vev/internal/ports" | ||
| 39 | scopy "github.com/bnema/vev/internal/usecase/copy" | 39 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 40 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 40 | "github.com/bnema/vev/pkg/vt" | 41 | "github.com/bnema/vev/pkg/vt" | ||
| 41 | ) | 42 | ) | ||
| 42 | 43 | ||||
| @@ -56,26 +57,33 @@ type session struct { | |||||
| 56 | createdAt int64 | 57 | createdAt int64 | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | - | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | 60 | + | // tab is a pane layout container. In the single-leaf case it preserves the old |
| 60 | - | // applies back-pressure by collapsing (a full buffer means "a render is | 61 | + | // one-PTY tab behavior; pane owns PTY/screen/scrollback/render scheduling state. |
| 61 | - | // already pending"), never by blocking the reader. | |||
| 62 | type tab struct { | 62 | type tab struct { | ||
| 63 | + | // Compatibility aliases for the single focused pane. Production call sites use | |||
| 64 | + | // focusedPane/all-pane loops; tests still inspect the single-leaf state directly. | |||
| 63 | pty ports.PTY | 65 | pty ports.PTY | ||
| 64 | - | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | |||
| 65 | screen *vt.Screen | 66 | screen *vt.Screen | ||
| 66 | scrollback *scopy.Scrollback | 67 | scrollback *scopy.Scrollback | ||
| 67 | dirty chan struct{} | 68 | dirty chan struct{} | ||
| 68 | flush chan struct{} | 69 | flush chan struct{} | ||
| 69 | syncGen uint64 | 70 | syncGen uint64 | ||
| 71 | + | ||||
| 72 | + | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | |||
| 73 | + | ||||
| 74 | + | tree *layout.Tree | |||
| 75 | + | panes map[layout.PaneID]*pane | |||
| 76 | + | nextPaneID int | |||
| 77 | + | size domain.Size | |||
| 78 | + | ctx context.Context | |||
| 79 | + | cancel context.CancelFunc | |||
| 80 | + | ||||
| 70 | // previewClient tracks the one client currently previewing this tab in the picker. | 81 | // previewClient tracks the one client currently previewing this tab in the picker. | ||
| 71 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | 82 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | ||
| 72 | previewClient *attachedClient | 83 | previewClient *attachedClient | ||
| 73 | // attention and attentionAt are guarded by the owning session.mu. | 84 | // attention and attentionAt are guarded by the owning session.mu. | ||
| 74 | attention bool | 85 | attention bool | ||
| 75 | attentionAt time.Time | 86 | attentionAt time.Time | ||
| 76 | - | size domain.Size | |||
| 77 | - | ctx context.Context | |||
| 78 | - | cancel context.CancelFunc | |||
| 79 | } | 87 | } | ||
| 80 | 88 | ||||
| 81 | // attachedClient is a client currently attached to a session's tab. rend is | 89 | // attachedClient is a client currently attached to a session's tab. rend is | ||
| @@ -183,7 +191,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 183 | tb := newTab(pty, tbSize) | 191 | tb := newTab(pty, tbSize) | ||
| 184 | if client != nil { | 192 | if client != nil { | ||
| 185 | t := client.getTheme() | 193 | t := client.getTheme() | ||
| 186 | - | tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | 194 | + | tb.mu.Lock() |
| 195 | + | p := tb.focusedPane() | |||
| 196 | + | tb.mu.Unlock() | |||
| 197 | + | if p != nil { | |||
| 198 | + | p.mu.Lock() | |||
| 199 | + | p.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG) | |||
| 200 | + | p.mu.Unlock() | |||
| 201 | + | } | |||
| 187 | } | 202 | } | ||
| 188 | d.mu.Lock() | 203 | d.mu.Lock() | ||
| 189 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | 204 | if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil { | ||
| @@ -202,19 +217,56 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 202 | } | 217 | } | ||
| 203 | 218 | ||||
| 204 | func newTab(pty ports.PTY, sz domain.Size) *tab { | 219 | func newTab(pty ports.PTY, sz domain.Size) *tab { | ||
| 205 | - | sb := scopy.NewScrollback(defaultScrollbackRows) | 220 | + | id := layout.PaneID("pane-1") |
| 206 | - | screen := vt.NewScreen(sz.Cols, sz.Rows) | 221 | + | p := newPane(id, pty, sz) |
| 207 | - | screen.OnLineEvicted = sb.Append | |||
| 208 | return &tab{ | 222 | return &tab{ | ||
| 209 | - | pty: pty, | 223 | + | pty: p.pty, |
| 210 | - | screen: screen, | 224 | + | screen: p.screen, |
| 211 | - | scrollback: sb, | 225 | + | scrollback: p.scrollback, |
| 212 | - | dirty: make(chan struct{}, 1), | 226 | + | dirty: p.dirty, |
| 213 | - | flush: make(chan struct{}, 1), | 227 | + | flush: p.flush, |
| 228 | + | tree: layout.NewTree(id), | |||
| 229 | + | panes: map[layout.PaneID]*pane{id: p}, | |||
| 230 | + | nextPaneID: 2, | |||
| 214 | size: sz, | 231 | size: sz, | ||
| 215 | } | 232 | } | ||
| 216 | } | 233 | } | ||
| 217 | 234 | ||||
| 235 | + | func (tb *tab) focusedPane() *pane { | |||
| 236 | + | if tb == nil { | |||
| 237 | + | return nil | |||
| 238 | + | } | |||
| 239 | + | if tb.tree != nil && tb.panes != nil { | |||
| 240 | + | p := tb.panes[tb.tree.Focus] | |||
| 241 | + | if p == nil { | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | if len(tb.panes) == 1 && ((tb.pty != nil && tb.pty != p.pty) || (tb.screen != nil && tb.screen != p.screen) || (tb.scrollback != nil && tb.scrollback != p.scrollback) || (tb.dirty != nil && tb.dirty != p.dirty) || (tb.flush != nil && tb.flush != p.flush)) { | |||
| 245 | + | return &pane{id: p.id, pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, rect: p.rect, ctx: tb.ctx, cancel: tb.cancel} | |||
| 246 | + | } | |||
| 247 | + | return p | |||
| 248 | + | } | |||
| 249 | + | if tb.pty != nil || tb.screen != nil || tb.scrollback != nil { | |||
| 250 | + | return &pane{pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, ctx: tb.ctx, cancel: tb.cancel} | |||
| 251 | + | } | |||
| 252 | + | return nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func (tb *tab) panesSnapshot() []*pane { | |||
| 256 | + | if tb == nil { | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | p0 := tb.focusedPane() | |||
| 260 | + | if len(tb.panes) == 0 && p0 != nil { | |||
| 261 | + | return []*pane{p0} | |||
| 262 | + | } | |||
| 263 | + | out := make([]*pane, 0, len(tb.panes)) | |||
| 264 | + | for _, p := range tb.panes { | |||
| 265 | + | out = append(out, p) | |||
| 266 | + | } | |||
| 267 | + | return out | |||
| 268 | + | } | |||
| 269 | + | ||||
| 218 | func tabSize(clientSize domain.Size) domain.Size { | 270 | func tabSize(clientSize domain.Size) domain.Size { | ||
| 219 | if !clientSize.Valid() { | 271 | if !clientSize.Valid() { | ||
| 220 | clientSize = defaultSize | 272 | clientSize = defaultSize | ||
| @@ -224,9 +276,18 @@ func tabSize(clientSize domain.Size) domain.Size { | |||||
| 224 | } | 276 | } | ||
| 225 | 277 | ||||
| 226 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | 278 | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | ||
| 279 | + | tb.mu.Lock() | |||
| 280 | + | panes := tb.panesSnapshot() | |||
| 281 | + | tb.mu.Unlock() | |||
| 282 | + | for _, p := range panes { | |||
| 283 | + | d.startPaneGoroutines(sess, tb, p) | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 227 | d.sessWg.Add(2) | 288 | d.sessWg.Add(2) | ||
| 228 | - | go d.ptyReader(sess, tb) | 289 | + | go d.ptyReader(sess, tb, p) |
| 229 | - | go d.scheduler(sess, tb) | 290 | + | go d.scheduler(sess, tb, p) |
| 230 | } | 291 | } | ||
| 231 | 292 | ||||
| 232 | // attachClient makes ac the session's current client, displacing any prior one | 293 | // attachClient makes ac the session's current client, displacing any prior one | ||
| @@ -345,7 +406,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 345 | if tb.cancel != nil { | 406 | if tb.cancel != nil { | ||
| 346 | tb.cancel() | 407 | tb.cancel() | ||
| 347 | } | 408 | } | ||
| 348 | - | _ = tb.pty.Close() | 409 | + | tb.mu.Lock() |
| 410 | + | panes := tb.panesSnapshot() | |||
| 411 | + | tb.mu.Unlock() | |||
| 412 | + | for _, p := range panes { | |||
| 413 | + | if p.cancel != nil { | |||
| 414 | + | p.cancel() | |||
| 415 | + | } | |||
| 416 | + | _ = p.pty.Close() | |||
| 417 | + | } | |||
| 349 | if repaint && ac != nil { | 418 | if repaint && ac != nil { | ||
| 350 | d.paint(sess, ac, true) | 419 | d.paint(sess, ac, true) | ||
| 351 | } | 420 | } | ||
| @@ -425,7 +494,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 425 | sess.mu.Unlock() | 494 | sess.mu.Unlock() | ||
| 426 | for _, tb := range tabs { | 495 | for _, tb := range tabs { | ||
| 427 | d.clearDestroyedTabPreview(tb) | 496 | d.clearDestroyedTabPreview(tb) | ||
| 428 | - | _ = tb.pty.Close() | 497 | + | tb.mu.Lock() |
| 498 | + | panes := tb.panesSnapshot() | |||
| 499 | + | tb.mu.Unlock() | |||
| 500 | + | for _, p := range panes { | |||
| 501 | + | if p.cancel != nil { | |||
| 502 | + | p.cancel() | |||
| 503 | + | } | |||
| 504 | + | _ = p.pty.Close() | |||
| 505 | + | } | |||
| 429 | } | 506 | } | ||
| 430 | if empty { | 507 | if empty { | ||
| 431 | d.doneOnce.Do(func() { close(d.done) }) | 508 | d.doneOnce.Do(func() { close(d.done) }) | ||
| @@ -509,7 +586,13 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||||
| 509 | if tb == nil { | 586 | if tb == nil { | ||
| 510 | return | 587 | return | ||
| 511 | } | 588 | } | ||
| 512 | - | cwd, err := d.procCwd(tb.pty.Pid()) | 589 | + | tb.mu.Lock() |
| 590 | + | p := tb.focusedPane() | |||
| 591 | + | tb.mu.Unlock() | |||
| 592 | + | if p == nil { | |||
| 593 | + | return | |||
| 594 | + | } | |||
| 595 | + | cwd, err := d.procCwd(p.pty.Pid()) | |||
| 513 | if err != nil || cwd == "" { | 596 | if err != nil || cwd == "" { | ||
| 514 | return | 597 | return | ||
| 515 | } | 598 | } | ||
M internal/usecase/daemon/status.go +5 -6
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
M internal/usecase/daemon/wire.go +5 -6
| @@ -17,12 +17,11 @@ | |||
| 17 | 17 | // when the parent context is cancelled (graceful shutdown notifies any | |
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | - | // Daemon.mu. When more than one is held the order is always | |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | - | // a slow client. | |
| 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 26 | 25 | package daemon | |
| 27 | 26 | ||
| 28 | 27 | import ( | |
| @@ -17,12 +17,11 @@ | |||||
| 17 | // when the parent context is cancelled (graceful shutdown notifies any | 17 | // when the parent context is cancelled (graceful shutdown notifies any | ||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | - | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | + | // Locking: a pane's screen/scrollback and per-client renderer shadow are |
| 21 | - | // by tab.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by |
| 22 | - | // Daemon.mu. When more than one is held the order is always | 22 | + | // session.mu; the registry by Daemon.mu. When more than one is held the order |
| 23 | - | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | + | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. |
| 24 | - | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | 24 | + | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. |
| 25 | - | // a slow client. | |||
| 26 | package daemon | 25 | package daemon | ||
| 27 | 26 | ||||
| 28 | import ( | 27 | import ( | ||
M internal/usecase/keys/router.go +75 -11
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package keys | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "slices" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | "unicode/utf8" | |
| @@ -30,6 +31,10 @@ const ( | |||
| 30 | 31 | ActionSwitchTab8 | |
| 31 | 32 | ActionSwitchTab9 | |
| 32 | 33 | ActionJumpAttention | |
| 34 | + | ActionFocusPaneLeft | |
| 35 | + | ActionFocusPaneRight | |
| 36 | + | ActionFocusPaneUp | |
| 37 | + | ActionFocusPaneDown | |
| 33 | 38 | ) | |
| 34 | 39 | ||
| 35 | 40 | // Handler receives router outputs. Forward is called only for bytes that should | |
| @@ -98,13 +103,23 @@ func (r *Router) route(data []byte) { | |||
| 98 | 103 | r.retainESC() | |
| 99 | 104 | return | |
| 100 | 105 | } | |
| 106 | + | remaining := data[i+1:] | |
| 107 | + | if action, size, ok, partial := altArrowCSI(remaining); ok { | |
| 108 | + | flush() | |
| 109 | + | r.h.Action(action) | |
| 110 | + | i += 1 + size | |
| 111 | + | continue | |
| 112 | + | } else if partial { | |
| 113 | + | flush() | |
| 114 | + | r.retainESC(remaining) | |
| 115 | + | return | |
| 116 | + | } | |
| 101 | 117 | next := data[i+1] | |
| 102 | 118 | if passThroughPrefix(next) { | |
| 103 | 119 | buf = append(buf, ESC, next) | |
| 104 | 120 | i += 2 | |
| 105 | 121 | continue | |
| 106 | 122 | } | |
| 107 | - | remaining := data[i+1:] | |
| 108 | 123 | if action, size, ok := binding(remaining); ok { | |
| 109 | 124 | flush() | |
| 110 | 125 | r.h.Action(action) | |
| @@ -123,11 +138,14 @@ func (r *Router) route(data []byte) { | |||
| 123 | 138 | } | |
| 124 | 139 | ||
| 125 | 140 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |
| 126 | - | next := data[0] | |
| 127 | - | if passThroughPrefix(next) { | |
| 128 | - | r.forward([]byte{ESC, next}) | |
| 129 | - | return 1 | |
| 141 | + | if action, size, ok, partial := altArrowCSI(data); ok { | |
| 142 | + | r.h.Action(action) | |
| 143 | + | return size | |
| 144 | + | } else if partial { | |
| 145 | + | r.retainESC(data) | |
| 146 | + | return len(data) | |
| 130 | 147 | } | |
| 148 | + | next := data[0] | |
| 131 | 149 | if action, size, ok := binding(data); ok { | |
| 132 | 150 | r.h.Action(action) | |
| 133 | 151 | return size | |
| @@ -136,15 +154,19 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |||
| 136 | 154 | r.retainESC(data) | |
| 137 | 155 | return len(data) | |
| 138 | 156 | } | |
| 139 | - | key, size := utf8.DecodeRune(data) | |
| 157 | + | _, size := utf8.DecodeRune(data) | |
| 140 | 158 | if size > 1 { | |
| 141 | 159 | r.forward(append([]byte{ESC}, data[:size]...)) | |
| 142 | 160 | return size | |
| 143 | 161 | } | |
| 144 | - | if key == utf8.RuneError && pendingAltLen > 0 { | |
| 162 | + | if pendingAltLen > 0 { | |
| 145 | 163 | r.forward(append([]byte{ESC}, data[:pendingAltLen]...)) | |
| 146 | 164 | return pendingAltLen | |
| 147 | 165 | } | |
| 166 | + | if passThroughPrefix(next) { | |
| 167 | + | r.forward([]byte{ESC, next}) | |
| 168 | + | return 1 | |
| 169 | + | } | |
| 148 | 170 | r.forward([]byte{ESC}) | |
| 149 | 171 | return 0 | |
| 150 | 172 | } | |
| @@ -200,6 +222,50 @@ func (r *Router) forward(data []byte) { | |||
| 200 | 222 | ||
| 201 | 223 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |
| 202 | 224 | ||
| 225 | + | func altArrowCSI(data []byte) (Action, int, bool, bool) { | |
| 226 | + | const seqLen = len("[1;3A") | |
| 227 | + | if len(data) > seqLen { | |
| 228 | + | return 0, 0, false, false | |
| 229 | + | } | |
| 230 | + | if len(data) < seqLen { | |
| 231 | + | return 0, 0, false, hasAltArrowCSIPrefix(data) | |
| 232 | + | } | |
| 233 | + | if data[0] != '[' || data[1] != '1' || data[2] != ';' || (data[3] != '3' && data[3] != '9') { | |
| 234 | + | return 0, 0, false, false | |
| 235 | + | } | |
| 236 | + | switch data[4] { | |
| 237 | + | case 'A': | |
| 238 | + | return ActionFocusPaneUp, seqLen, true, false | |
| 239 | + | case 'B': | |
| 240 | + | return ActionFocusPaneDown, seqLen, true, false | |
| 241 | + | case 'C': | |
| 242 | + | return ActionFocusPaneRight, seqLen, true, false | |
| 243 | + | case 'D': | |
| 244 | + | return ActionFocusPaneLeft, seqLen, true, false | |
| 245 | + | default: | |
| 246 | + | return 0, 0, false, false | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func hasAltArrowCSIPrefix(data []byte) bool { | |
| 251 | + | if len(data) == 0 || len(data) >= len("[1;3A") { | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func matchesPrefix(data []byte, want string) bool { | |
| 258 | + | if len(data) > len(want) { | |
| 259 | + | return false | |
| 260 | + | } | |
| 261 | + | for i := range data { | |
| 262 | + | if data[i] != want[i] { | |
| 263 | + | return false | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | return true | |
| 267 | + | } | |
| 268 | + | ||
| 203 | 269 | func binding(data []byte) (Action, int, bool) { | |
| 204 | 270 | if len(data) == 0 { | |
| 205 | 271 | return 0, 0, false | |
| @@ -238,10 +304,8 @@ var topRowDigitAliases = [][]rune{ | |||
| 238 | 304 | // Ctrl+digit bindings can share the same layout support. | |
| 239 | 305 | func topRowDigitIndex(key rune) (int, bool) { | |
| 240 | 306 | for idx, aliases := range topRowDigitAliases { | |
| 241 | - | for _, alias := range aliases { | |
| 242 | - | if key == alias { | |
| 243 | - | return idx, true | |
| 244 | - | } | |
| 307 | + | if slices.Contains(aliases, key) { | |
| 308 | + | return idx, true | |
| 245 | 309 | } | |
| 246 | 310 | } | |
| 247 | 311 | return 0, false | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package keys | 3 | package keys | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "slices" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | "unicode/utf8" | 9 | "unicode/utf8" | ||
| @@ -30,6 +31,10 @@ const ( | |||||
| 30 | ActionSwitchTab8 | 31 | ActionSwitchTab8 | ||
| 31 | ActionSwitchTab9 | 32 | ActionSwitchTab9 | ||
| 32 | ActionJumpAttention | 33 | ActionJumpAttention | ||
| 34 | + | ActionFocusPaneLeft | |||
| 35 | + | ActionFocusPaneRight | |||
| 36 | + | ActionFocusPaneUp | |||
| 37 | + | ActionFocusPaneDown | |||
| 33 | ) | 38 | ) | ||
| 34 | 39 | ||||
| 35 | // Handler receives router outputs. Forward is called only for bytes that should | 40 | // Handler receives router outputs. Forward is called only for bytes that should | ||
| @@ -98,13 +103,23 @@ func (r *Router) route(data []byte) { | |||||
| 98 | r.retainESC() | 103 | r.retainESC() | ||
| 99 | return | 104 | return | ||
| 100 | } | 105 | } | ||
| 106 | + | remaining := data[i+1:] | |||
| 107 | + | if action, size, ok, partial := altArrowCSI(remaining); ok { | |||
| 108 | + | flush() | |||
| 109 | + | r.h.Action(action) | |||
| 110 | + | i += 1 + size | |||
| 111 | + | continue | |||
| 112 | + | } else if partial { | |||
| 113 | + | flush() | |||
| 114 | + | r.retainESC(remaining) | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 101 | next := data[i+1] | 117 | next := data[i+1] | ||
| 102 | if passThroughPrefix(next) { | 118 | if passThroughPrefix(next) { | ||
| 103 | buf = append(buf, ESC, next) | 119 | buf = append(buf, ESC, next) | ||
| 104 | i += 2 | 120 | i += 2 | ||
| 105 | continue | 121 | continue | ||
| 106 | } | 122 | } | ||
| 107 | - | remaining := data[i+1:] | |||
| 108 | if action, size, ok := binding(remaining); ok { | 123 | if action, size, ok := binding(remaining); ok { | ||
| 109 | flush() | 124 | flush() | ||
| 110 | r.h.Action(action) | 125 | r.h.Action(action) | ||
| @@ -123,11 +138,14 @@ func (r *Router) route(data []byte) { | |||||
| 123 | } | 138 | } | ||
| 124 | 139 | ||||
| 125 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | 140 | func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | ||
| 126 | - | next := data[0] | 141 | + | if action, size, ok, partial := altArrowCSI(data); ok { |
| 127 | - | if passThroughPrefix(next) { | 142 | + | r.h.Action(action) |
| 128 | - | r.forward([]byte{ESC, next}) | 143 | + | return size |
| 129 | - | return 1 | 144 | + | } else if partial { |
| 145 | + | r.retainESC(data) | |||
| 146 | + | return len(data) | |||
| 130 | } | 147 | } | ||
| 148 | + | next := data[0] | |||
| 131 | if action, size, ok := binding(data); ok { | 149 | if action, size, ok := binding(data); ok { | ||
| 132 | r.h.Action(action) | 150 | r.h.Action(action) | ||
| 133 | return size | 151 | return size | ||
| @@ -136,15 +154,19 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int { | |||||
| 136 | r.retainESC(data) | 154 | r.retainESC(data) | ||
| 137 | return len(data) | 155 | return len(data) | ||
| 138 | } | 156 | } | ||
| 139 | - | key, size := utf8.DecodeRune(data) | 157 | + | _, size := utf8.DecodeRune(data) |
| 140 | if size > 1 { | 158 | if size > 1 { | ||
| 141 | r.forward(append([]byte{ESC}, data[:size]...)) | 159 | r.forward(append([]byte{ESC}, data[:size]...)) | ||
| 142 | return size | 160 | return size | ||
| 143 | } | 161 | } | ||
| 144 | - | if key == utf8.RuneError && pendingAltLen > 0 { | 162 | + | if pendingAltLen > 0 { |
| 145 | r.forward(append([]byte{ESC}, data[:pendingAltLen]...)) | 163 | r.forward(append([]byte{ESC}, data[:pendingAltLen]...)) | ||
| 146 | return pendingAltLen | 164 | return pendingAltLen | ||
| 147 | } | 165 | } | ||
| 166 | + | if passThroughPrefix(next) { | |||
| 167 | + | r.forward([]byte{ESC, next}) | |||
| 168 | + | return 1 | |||
| 169 | + | } | |||
| 148 | r.forward([]byte{ESC}) | 170 | r.forward([]byte{ESC}) | ||
| 149 | return 0 | 171 | return 0 | ||
| 150 | } | 172 | } | ||
| @@ -200,6 +222,50 @@ func (r *Router) forward(data []byte) { | |||||
| 200 | 222 | ||||
| 201 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | 223 | func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | ||
| 202 | 224 | ||||
| 225 | + | func altArrowCSI(data []byte) (Action, int, bool, bool) { | |||
| 226 | + | const seqLen = len("[1;3A") | |||
| 227 | + | if len(data) > seqLen { | |||
| 228 | + | return 0, 0, false, false | |||
| 229 | + | } | |||
| 230 | + | if len(data) < seqLen { | |||
| 231 | + | return 0, 0, false, hasAltArrowCSIPrefix(data) | |||
| 232 | + | } | |||
| 233 | + | if data[0] != '[' || data[1] != '1' || data[2] != ';' || (data[3] != '3' && data[3] != '9') { | |||
| 234 | + | return 0, 0, false, false | |||
| 235 | + | } | |||
| 236 | + | switch data[4] { | |||
| 237 | + | case 'A': | |||
| 238 | + | return ActionFocusPaneUp, seqLen, true, false | |||
| 239 | + | case 'B': | |||
| 240 | + | return ActionFocusPaneDown, seqLen, true, false | |||
| 241 | + | case 'C': | |||
| 242 | + | return ActionFocusPaneRight, seqLen, true, false | |||
| 243 | + | case 'D': | |||
| 244 | + | return ActionFocusPaneLeft, seqLen, true, false | |||
| 245 | + | default: | |||
| 246 | + | return 0, 0, false, false | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func hasAltArrowCSIPrefix(data []byte) bool { | |||
| 251 | + | if len(data) == 0 || len(data) >= len("[1;3A") { | |||
| 252 | + | return false | |||
| 253 | + | } | |||
| 254 | + | return matchesPrefix(data, "[1;3") || matchesPrefix(data, "[1;9") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func matchesPrefix(data []byte, want string) bool { | |||
| 258 | + | if len(data) > len(want) { | |||
| 259 | + | return false | |||
| 260 | + | } | |||
| 261 | + | for i := range data { | |||
| 262 | + | if data[i] != want[i] { | |||
| 263 | + | return false | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | return true | |||
| 267 | + | } | |||
| 268 | + | ||||
| 203 | func binding(data []byte) (Action, int, bool) { | 269 | func binding(data []byte) (Action, int, bool) { | ||
| 204 | if len(data) == 0 { | 270 | if len(data) == 0 { | ||
| 205 | return 0, 0, false | 271 | return 0, 0, false | ||
| @@ -238,10 +304,8 @@ var topRowDigitAliases = [][]rune{ | |||||
| 238 | // Ctrl+digit bindings can share the same layout support. | 304 | // Ctrl+digit bindings can share the same layout support. | ||
| 239 | func topRowDigitIndex(key rune) (int, bool) { | 305 | func topRowDigitIndex(key rune) (int, bool) { | ||
| 240 | for idx, aliases := range topRowDigitAliases { | 306 | for idx, aliases := range topRowDigitAliases { | ||
| 241 | - | for _, alias := range aliases { | 307 | + | if slices.Contains(aliases, key) { |
| 242 | - | if key == alias { | 308 | + | return idx, true |
| 243 | - | return idx, true | |||
| 244 | - | } | |||
| 245 | } | 309 | } | ||
| 246 | } | 310 | } | ||
| 247 | return 0, false | 311 | return 0, false | ||
M internal/usecase/keys/router_test.go +75 -0
| @@ -244,6 +244,81 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) { | |
| 248 | + | cases := []struct { | |
| 249 | + | name string | |
| 250 | + | in []byte | |
| 251 | + | want Action | |
| 252 | + | }{ | |
| 253 | + | {name: "alt up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp}, | |
| 254 | + | {name: "alt down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown}, | |
| 255 | + | {name: "alt right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight}, | |
| 256 | + | {name: "alt left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft}, | |
| 257 | + | {name: "meta up", in: []byte{ESC, '[', '1', ';', '9', 'A'}, want: ActionFocusPaneUp}, | |
| 258 | + | {name: "meta down", in: []byte{ESC, '[', '1', ';', '9', 'B'}, want: ActionFocusPaneDown}, | |
| 259 | + | {name: "meta right", in: []byte{ESC, '[', '1', ';', '9', 'C'}, want: ActionFocusPaneRight}, | |
| 260 | + | {name: "meta left", in: []byte{ESC, '[', '1', ';', '9', 'D'}, want: ActionFocusPaneLeft}, | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | for _, tc := range cases { | |
| 264 | + | t.Run(tc.name, func(t *testing.T) { | |
| 265 | + | clk := &fakeClock{} | |
| 266 | + | h := &captureHandler{} | |
| 267 | + | NewRouter(clk, h).Route(tc.in) | |
| 268 | + | require.Equal(t, []Action{tc.want}, h.actions) | |
| 269 | + | require.Empty(t, h.forwards) | |
| 270 | + | require.Empty(t, clk.timers) | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) { | |
| 276 | + | cases := []struct { | |
| 277 | + | name string | |
| 278 | + | reads [][]byte | |
| 279 | + | want Action | |
| 280 | + | timers int | |
| 281 | + | }{ | |
| 282 | + | {name: "esc then rest", reads: [][]byte{{ESC}, []byte("[1;3D")}, want: ActionFocusPaneLeft, timers: 1}, | |
| 283 | + | {name: "csi prefix then rest", reads: [][]byte{{ESC, '['}, []byte("1;3C")}, want: ActionFocusPaneRight, timers: 1}, | |
| 284 | + | {name: "modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '3'}, []byte("A")}, want: ActionFocusPaneUp, timers: 1}, | |
| 285 | + | {name: "meta modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '9'}, []byte("B")}, want: ActionFocusPaneDown, timers: 1}, | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | for _, tc := range cases { | |
| 289 | + | t.Run(tc.name, func(t *testing.T) { | |
| 290 | + | clk := &fakeClock{} | |
| 291 | + | h := &captureHandler{} | |
| 292 | + | r := NewRouter(clk, h) | |
| 293 | + | for _, read := range tc.reads { | |
| 294 | + | r.Route(read) | |
| 295 | + | } | |
| 296 | + | require.Equal(t, []Action{tc.want}, h.actions) | |
| 297 | + | require.Empty(t, h.forwards) | |
| 298 | + | require.Len(t, clk.timers, tc.timers) | |
| 299 | + | assertRouterPendingCleared(t, r) | |
| 300 | + | }) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestRouterPassesThroughBareArrows(t *testing.T) { | |
| 305 | + | for _, in := range [][]byte{ | |
| 306 | + | {ESC, '[', 'A'}, | |
| 307 | + | {ESC, '[', 'B'}, | |
| 308 | + | {ESC, '[', 'C'}, | |
| 309 | + | {ESC, '[', 'D'}, | |
| 310 | + | } { | |
| 311 | + | t.Run(string(in), func(t *testing.T) { | |
| 312 | + | clk := &fakeClock{} | |
| 313 | + | h := &captureHandler{} | |
| 314 | + | NewRouter(clk, h).Route(in) | |
| 315 | + | require.Empty(t, h.actions) | |
| 316 | + | require.Equal(t, [][]byte{in}, h.forwards) | |
| 317 | + | require.Empty(t, clk.timers) | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 247 | 322 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |
| 248 | 323 | clk := &fakeClock{} | |
| 249 | 324 | h := &captureHandler{} | |
| @@ -244,6 +244,81 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) { | |||||
| 244 | } | 244 | } | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | + | func TestRouterInterceptsAltArrowCSIForPaneFocus(t *testing.T) { | |||
| 248 | + | cases := []struct { | |||
| 249 | + | name string | |||
| 250 | + | in []byte | |||
| 251 | + | want Action | |||
| 252 | + | }{ | |||
| 253 | + | {name: "alt up", in: []byte{ESC, '[', '1', ';', '3', 'A'}, want: ActionFocusPaneUp}, | |||
| 254 | + | {name: "alt down", in: []byte{ESC, '[', '1', ';', '3', 'B'}, want: ActionFocusPaneDown}, | |||
| 255 | + | {name: "alt right", in: []byte{ESC, '[', '1', ';', '3', 'C'}, want: ActionFocusPaneRight}, | |||
| 256 | + | {name: "alt left", in: []byte{ESC, '[', '1', ';', '3', 'D'}, want: ActionFocusPaneLeft}, | |||
| 257 | + | {name: "meta up", in: []byte{ESC, '[', '1', ';', '9', 'A'}, want: ActionFocusPaneUp}, | |||
| 258 | + | {name: "meta down", in: []byte{ESC, '[', '1', ';', '9', 'B'}, want: ActionFocusPaneDown}, | |||
| 259 | + | {name: "meta right", in: []byte{ESC, '[', '1', ';', '9', 'C'}, want: ActionFocusPaneRight}, | |||
| 260 | + | {name: "meta left", in: []byte{ESC, '[', '1', ';', '9', 'D'}, want: ActionFocusPaneLeft}, | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | for _, tc := range cases { | |||
| 264 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 265 | + | clk := &fakeClock{} | |||
| 266 | + | h := &captureHandler{} | |||
| 267 | + | NewRouter(clk, h).Route(tc.in) | |||
| 268 | + | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 269 | + | require.Empty(t, h.forwards) | |||
| 270 | + | require.Empty(t, clk.timers) | |||
| 271 | + | }) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) { | |||
| 276 | + | cases := []struct { | |||
| 277 | + | name string | |||
| 278 | + | reads [][]byte | |||
| 279 | + | want Action | |||
| 280 | + | timers int | |||
| 281 | + | }{ | |||
| 282 | + | {name: "esc then rest", reads: [][]byte{{ESC}, []byte("[1;3D")}, want: ActionFocusPaneLeft, timers: 1}, | |||
| 283 | + | {name: "csi prefix then rest", reads: [][]byte{{ESC, '['}, []byte("1;3C")}, want: ActionFocusPaneRight, timers: 1}, | |||
| 284 | + | {name: "modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '3'}, []byte("A")}, want: ActionFocusPaneUp, timers: 1}, | |||
| 285 | + | {name: "meta modifier prefix then final", reads: [][]byte{{ESC, '[', '1', ';', '9'}, []byte("B")}, want: ActionFocusPaneDown, timers: 1}, | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | for _, tc := range cases { | |||
| 289 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 290 | + | clk := &fakeClock{} | |||
| 291 | + | h := &captureHandler{} | |||
| 292 | + | r := NewRouter(clk, h) | |||
| 293 | + | for _, read := range tc.reads { | |||
| 294 | + | r.Route(read) | |||
| 295 | + | } | |||
| 296 | + | require.Equal(t, []Action{tc.want}, h.actions) | |||
| 297 | + | require.Empty(t, h.forwards) | |||
| 298 | + | require.Len(t, clk.timers, tc.timers) | |||
| 299 | + | assertRouterPendingCleared(t, r) | |||
| 300 | + | }) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func TestRouterPassesThroughBareArrows(t *testing.T) { | |||
| 305 | + | for _, in := range [][]byte{ | |||
| 306 | + | {ESC, '[', 'A'}, | |||
| 307 | + | {ESC, '[', 'B'}, | |||
| 308 | + | {ESC, '[', 'C'}, | |||
| 309 | + | {ESC, '[', 'D'}, | |||
| 310 | + | } { | |||
| 311 | + | t.Run(string(in), func(t *testing.T) { | |||
| 312 | + | clk := &fakeClock{} | |||
| 313 | + | h := &captureHandler{} | |||
| 314 | + | NewRouter(clk, h).Route(in) | |||
| 315 | + | require.Empty(t, h.actions) | |||
| 316 | + | require.Equal(t, [][]byte{in}, h.forwards) | |||
| 317 | + | require.Empty(t, clk.timers) | |||
| 318 | + | }) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 247 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | 322 | func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | ||
| 248 | clk := &fakeClock{} | 323 | clk := &fakeClock{} | ||
| 249 | h := &captureHandler{} | 324 | h := &captureHandler{} | ||
A internal/usecase/layout/geometry.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // Solve returns deterministic pane placements for root inside area. | |
| 6 | + | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | |
| 7 | + | if root == nil || area.Width <= 0 || area.Height <= 0 { | |
| 8 | + | return nil, false | |
| 9 | + | } | |
| 10 | + | var out []Placement | |
| 11 | + | if !solve(root, area, &out) { | |
| 12 | + | return nil, false | |
| 13 | + | } | |
| 14 | + | return out, true | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |
| 18 | + | switch n.Kind { | |
| 19 | + | case Leaf: | |
| 20 | + | if r.Width < MinPaneCols || r.Height < MinPaneRows { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | *out = append(*out, Placement{ID: n.Leaf, Content: r}) | |
| 24 | + | return true | |
| 25 | + | case Split: | |
| 26 | + | return solveSplit(n, r, out) | |
| 27 | + | case Stack: | |
| 28 | + | return solveStack(n, r, out) | |
| 29 | + | default: | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool { | |
| 35 | + | count := len(n.Children) | |
| 36 | + | if count == 0 { | |
| 37 | + | return false | |
| 38 | + | } | |
| 39 | + | if count == 1 { | |
| 40 | + | return solve(n.Children[0], r, out) | |
| 41 | + | } | |
| 42 | + | if n.Dir == Horizontal { | |
| 43 | + | usable := r.Width - (count - 1) | |
| 44 | + | if usable < count*MinPaneCols || r.Height < MinPaneRows { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | base, rem := usable/count, usable%count | |
| 48 | + | x := r.X | |
| 49 | + | for i, child := range n.Children { | |
| 50 | + | w := base | |
| 51 | + | if i < rem { | |
| 52 | + | w++ | |
| 53 | + | } | |
| 54 | + | if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) { | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | x += w + 1 | |
| 58 | + | } | |
| 59 | + | return true | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | usable := r.Height - (count - 1) | |
| 63 | + | if usable < count*MinPaneRows || r.Width < MinPaneCols { | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | base, rem := usable/count, usable%count | |
| 67 | + | y := r.Y | |
| 68 | + | for i, child := range n.Children { | |
| 69 | + | h := base | |
| 70 | + | if i < rem { | |
| 71 | + | h++ | |
| 72 | + | } | |
| 73 | + | if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) { | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | y += h + 1 | |
| 77 | + | } | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | |
| 82 | + | count := len(n.Children) | |
| 83 | + | if count == 0 || r.Width < MinPaneCols || r.Height < count+1 { | |
| 84 | + | return false | |
| 85 | + | } | |
| 86 | + | idx := -1 | |
| 87 | + | for i, child := range n.Children { | |
| 88 | + | if child.Kind != Leaf { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | if child.Leaf == n.Expanded { | |
| 92 | + | idx = i | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | if idx < 0 { | |
| 96 | + | idx = 0 | |
| 97 | + | } | |
| 98 | + | contentRows := r.Height - count | |
| 99 | + | for i, child := range n.Children { | |
| 100 | + | if i < idx { | |
| 101 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |
| 102 | + | continue | |
| 103 | + | } | |
| 104 | + | if i == idx { | |
| 105 | + | titleY := r.Y + i | |
| 106 | + | *out = append(*out, Placement{ | |
| 107 | + | ID: child.Leaf, | |
| 108 | + | TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, | |
| 109 | + | Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows}, | |
| 110 | + | InStack: true, | |
| 111 | + | }) | |
| 112 | + | continue | |
| 113 | + | } | |
| 114 | + | titleY := r.Y + idx + 1 + contentRows + (i - idx - 1) | |
| 115 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |
| 116 | + | } | |
| 117 | + | return true | |
| 118 | + | } | |
| @@ -0,0 +1,118 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // Solve returns deterministic pane placements for root inside area. | |||
| 6 | + | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | |||
| 7 | + | if root == nil || area.Width <= 0 || area.Height <= 0 { | |||
| 8 | + | return nil, false | |||
| 9 | + | } | |||
| 10 | + | var out []Placement | |||
| 11 | + | if !solve(root, area, &out) { | |||
| 12 | + | return nil, false | |||
| 13 | + | } | |||
| 14 | + | return out, true | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 18 | + | switch n.Kind { | |||
| 19 | + | case Leaf: | |||
| 20 | + | if r.Width < MinPaneCols || r.Height < MinPaneRows { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | *out = append(*out, Placement{ID: n.Leaf, Content: r}) | |||
| 24 | + | return true | |||
| 25 | + | case Split: | |||
| 26 | + | return solveSplit(n, r, out) | |||
| 27 | + | case Stack: | |||
| 28 | + | return solveStack(n, r, out) | |||
| 29 | + | default: | |||
| 30 | + | return false | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 35 | + | count := len(n.Children) | |||
| 36 | + | if count == 0 { | |||
| 37 | + | return false | |||
| 38 | + | } | |||
| 39 | + | if count == 1 { | |||
| 40 | + | return solve(n.Children[0], r, out) | |||
| 41 | + | } | |||
| 42 | + | if n.Dir == Horizontal { | |||
| 43 | + | usable := r.Width - (count - 1) | |||
| 44 | + | if usable < count*MinPaneCols || r.Height < MinPaneRows { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | base, rem := usable/count, usable%count | |||
| 48 | + | x := r.X | |||
| 49 | + | for i, child := range n.Children { | |||
| 50 | + | w := base | |||
| 51 | + | if i < rem { | |||
| 52 | + | w++ | |||
| 53 | + | } | |||
| 54 | + | if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) { | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | x += w + 1 | |||
| 58 | + | } | |||
| 59 | + | return true | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | usable := r.Height - (count - 1) | |||
| 63 | + | if usable < count*MinPaneRows || r.Width < MinPaneCols { | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | base, rem := usable/count, usable%count | |||
| 67 | + | y := r.Y | |||
| 68 | + | for i, child := range n.Children { | |||
| 69 | + | h := base | |||
| 70 | + | if i < rem { | |||
| 71 | + | h++ | |||
| 72 | + | } | |||
| 73 | + | if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) { | |||
| 74 | + | return false | |||
| 75 | + | } | |||
| 76 | + | y += h + 1 | |||
| 77 | + | } | |||
| 78 | + | return true | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 82 | + | count := len(n.Children) | |||
| 83 | + | if count == 0 || r.Width < MinPaneCols || r.Height < count+1 { | |||
| 84 | + | return false | |||
| 85 | + | } | |||
| 86 | + | idx := -1 | |||
| 87 | + | for i, child := range n.Children { | |||
| 88 | + | if child.Kind != Leaf { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | if child.Leaf == n.Expanded { | |||
| 92 | + | idx = i | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | if idx < 0 { | |||
| 96 | + | idx = 0 | |||
| 97 | + | } | |||
| 98 | + | contentRows := r.Height - count | |||
| 99 | + | for i, child := range n.Children { | |||
| 100 | + | if i < idx { | |||
| 101 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |||
| 102 | + | continue | |||
| 103 | + | } | |||
| 104 | + | if i == idx { | |||
| 105 | + | titleY := r.Y + i | |||
| 106 | + | *out = append(*out, Placement{ | |||
| 107 | + | ID: child.Leaf, | |||
| 108 | + | TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, | |||
| 109 | + | Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows}, | |||
| 110 | + | InStack: true, | |||
| 111 | + | }) | |||
| 112 | + | continue | |||
| 113 | + | } | |||
| 114 | + | titleY := r.Y + idx + 1 + contentRows + (i - idx - 1) | |||
| 115 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |||
| 116 | + | } | |||
| 117 | + | return true | |||
| 118 | + | } | |||
A internal/usecase/layout/layout_test.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSolveSplitGeometry(t *testing.T) { | |
| 12 | + | t.Parallel() | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | root *Node | |
| 17 | + | area domain.Rect | |
| 18 | + | want map[PaneID]domain.Rect | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | name: "horizontal remainder to first children", | |
| 22 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}}, | |
| 23 | + | area: domain.Rect{Width: 64, Height: 5}, | |
| 24 | + | want: map[PaneID]domain.Rect{"a": {Width: 21, Height: 5}, "b": {X: 22, Width: 21, Height: 5}, "c": {X: 44, Width: 20, Height: 5}}, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "vertical remainder to first children", | |
| 28 | + | root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, | |
| 29 | + | area: domain.Rect{Width: 20, Height: 7}, | |
| 30 | + | want: map[PaneID]domain.Rect{"a": {Width: 20, Height: 3}, "b": {Y: 4, Width: 20, Height: 3}}, | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | for _, tt := range tests { | |
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |
| 36 | + | got, ok := Solve(tt.root, tt.area) | |
| 37 | + | require.True(t, ok) | |
| 38 | + | require.Len(t, got, len(tt.want)) | |
| 39 | + | for _, p := range got { | |
| 40 | + | require.Equal(t, tt.want[p.ID], p.Content) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestSolveNestedTrees(t *testing.T) { | |
| 47 | + | t.Parallel() | |
| 48 | + | root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 49 | + | NewLeaf("left"), | |
| 50 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, | |
| 51 | + | }} | |
| 52 | + | got, ok := Solve(root, domain.Rect{Width: 61, Height: 5}) | |
| 53 | + | require.True(t, ok) | |
| 54 | + | require.Equal(t, map[PaneID]domain.Rect{ | |
| 55 | + | "left": {Width: 30, Height: 5}, | |
| 56 | + | "top": {X: 31, Width: 30, Height: 2}, | |
| 57 | + | "bottom": {X: 31, Y: 3, Width: 30, Height: 2}, | |
| 58 | + | }, contents(got)) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestSolveStackFitAndOverflow(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"} | |
| 64 | + | got, ok := Solve(stack, domain.Rect{Width: 20, Height: 4}) | |
| 65 | + | require.True(t, ok) | |
| 66 | + | require.Equal(t, []Placement{ | |
| 67 | + | {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 68 | + | {ID: "b", Content: domain.Rect{Y: 2, Width: 20, Height: 1}, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, InStack: true}, | |
| 69 | + | {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 70 | + | }, got) | |
| 71 | + | ||
| 72 | + | _, ok = Solve(stack, domain.Rect{Width: 20, Height: 3}) | |
| 73 | + | require.False(t, ok) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | op func(*Tree) error | |
| 81 | + | }{ | |
| 82 | + | { | |
| 83 | + | name: "split refuses below min width", | |
| 84 | + | op: func(tr *Tree) error { | |
| 85 | + | return tr.Split("a", Right, true, "b", domain.Rect{Width: 40, Height: 2}) | |
| 86 | + | }, | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "stack refuses overflow", | |
| 90 | + | op: func(tr *Tree) error { | |
| 91 | + | require.NoError(t, tr.StackNew("a", "b", domain.Rect{Width: 20, Height: 3})) | |
| 92 | + | return tr.StackNew("b", "c", domain.Rect{Width: 20, Height: 3}) | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | for _, tt := range tests { | |
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |
| 99 | + | tr := NewTree("a") | |
| 100 | + | before := tr.clone() | |
| 101 | + | err := tt.op(tr) | |
| 102 | + | require.ErrorIs(t, err, ErrTooSmall) | |
| 103 | + | if tt.name == "stack refuses overflow" { | |
| 104 | + | require.Equal(t, PaneID("b"), tr.Focus) | |
| 105 | + | require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root)) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | require.Equal(t, before, tr) | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestTreeSurgeryShapes(t *testing.T) { | |
| 114 | + | t.Parallel() | |
| 115 | + | ||
| 116 | + | t.Run("split preserves and dissolves containers", func(t *testing.T) { | |
| 117 | + | tr := NewTree("a") | |
| 118 | + | require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 2})) | |
| 119 | + | require.Equal(t, Split, tr.Root.Kind) | |
| 120 | + | require.Equal(t, Horizontal, tr.Root.Dir) | |
| 121 | + | require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root)) | |
| 122 | + | require.NoError(t, tr.Close("a")) | |
| 123 | + | require.Equal(t, Leaf, tr.Root.Kind) | |
| 124 | + | require.Equal(t, PaneID("b"), tr.Root.Leaf) | |
| 125 | + | }) | |
| 126 | + | ||
| 127 | + | t.Run("nested split inserts on matching axis", func(t *testing.T) { | |
| 128 | + | tr := NewTree("a") | |
| 129 | + | require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 4})) | |
| 130 | + | require.NoError(t, tr.Split("b", Right, true, "c", domain.Rect{Width: 62, Height: 4})) | |
| 131 | + | require.Len(t, tr.Root.Children, 3) | |
| 132 | + | require.Equal(t, []PaneID{"a", "b", "c"}, leafIDs(tr.Root)) | |
| 133 | + | require.NoError(t, tr.Split("b", Down, true, "d", domain.Rect{Width: 62, Height: 5})) | |
| 134 | + | require.Equal(t, Vertical, tr.Root.Children[1].Dir) | |
| 135 | + | require.Equal(t, []PaneID{"a", "b", "d", "c"}, leafIDs(tr.Root)) | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestFocusDir(t *testing.T) { | |
| 140 | + | t.Parallel() | |
| 141 | + | area := domain.Rect{Width: 62, Height: 5} | |
| 142 | + | tests := []struct { | |
| 143 | + | name string | |
| 144 | + | focus PaneID | |
| 145 | + | dir Direction | |
| 146 | + | want PaneID | |
| 147 | + | }{ | |
| 148 | + | {"right chooses overlapping neighbor", "a", Right, "b"}, | |
| 149 | + | {"left chooses overlapping neighbor", "b", Left, "a"}, | |
| 150 | + | {"down chooses vertical neighbor", "b", Down, "c"}, | |
| 151 | + | {"up chooses vertical neighbor", "c", Up, "b"}, | |
| 152 | + | } | |
| 153 | + | for _, tt := range tests { | |
| 154 | + | t.Run(tt.name, func(t *testing.T) { | |
| 155 | + | tr := &Tree{Focus: tt.focus, Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |
| 156 | + | NewLeaf("a"), | |
| 157 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}}, | |
| 158 | + | }}} | |
| 159 | + | require.NoError(t, tr.FocusDir(tt.dir, area)) | |
| 160 | + | require.Equal(t, tt.want, tr.Focus) | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | t.Run("stack local vertical focus expands member", func(t *testing.T) { | |
| 165 | + | tr := &Tree{Focus: "b", Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}} | |
| 166 | + | require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 20, Height: 4})) | |
| 167 | + | require.Equal(t, PaneID("c"), tr.Focus) | |
| 168 | + | require.Equal(t, PaneID("c"), tr.Root.Expanded) | |
| 169 | + | require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4})) | |
| 170 | + | require.Equal(t, PaneID("b"), tr.Focus) | |
| 171 | + | }) | |
| 172 | + | ||
| 173 | + | t.Run("no pane in direction", func(t *testing.T) { | |
| 174 | + | tr := NewTree("a") | |
| 175 | + | err := tr.FocusDir(Left, domain.Rect{Width: 20, Height: 2}) | |
| 176 | + | require.True(t, errors.Is(err, ErrNoPane)) | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func contents(ps []Placement) map[PaneID]domain.Rect { | |
| 181 | + | out := make(map[PaneID]domain.Rect, len(ps)) | |
| 182 | + | for _, p := range ps { | |
| 183 | + | out[p.ID] = p.Content | |
| 184 | + | } | |
| 185 | + | return out | |
| 186 | + | } | |
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSolveSplitGeometry(t *testing.T) { | |||
| 12 | + | t.Parallel() | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | root *Node | |||
| 17 | + | area domain.Rect | |||
| 18 | + | want map[PaneID]domain.Rect | |||
| 19 | + | }{ | |||
| 20 | + | { | |||
| 21 | + | name: "horizontal remainder to first children", | |||
| 22 | + | root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}}, | |||
| 23 | + | area: domain.Rect{Width: 64, Height: 5}, | |||
| 24 | + | want: map[PaneID]domain.Rect{"a": {Width: 21, Height: 5}, "b": {X: 22, Width: 21, Height: 5}, "c": {X: 44, Width: 20, Height: 5}}, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "vertical remainder to first children", | |||
| 28 | + | root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, | |||
| 29 | + | area: domain.Rect{Width: 20, Height: 7}, | |||
| 30 | + | want: map[PaneID]domain.Rect{"a": {Width: 20, Height: 3}, "b": {Y: 4, Width: 20, Height: 3}}, | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | for _, tt := range tests { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | got, ok := Solve(tt.root, tt.area) | |||
| 37 | + | require.True(t, ok) | |||
| 38 | + | require.Len(t, got, len(tt.want)) | |||
| 39 | + | for _, p := range got { | |||
| 40 | + | require.Equal(t, tt.want[p.ID], p.Content) | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestSolveNestedTrees(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 49 | + | NewLeaf("left"), | |||
| 50 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, | |||
| 51 | + | }} | |||
| 52 | + | got, ok := Solve(root, domain.Rect{Width: 61, Height: 5}) | |||
| 53 | + | require.True(t, ok) | |||
| 54 | + | require.Equal(t, map[PaneID]domain.Rect{ | |||
| 55 | + | "left": {Width: 30, Height: 5}, | |||
| 56 | + | "top": {X: 31, Width: 30, Height: 2}, | |||
| 57 | + | "bottom": {X: 31, Y: 3, Width: 30, Height: 2}, | |||
| 58 | + | }, contents(got)) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestSolveStackFitAndOverflow(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"} | |||
| 64 | + | got, ok := Solve(stack, domain.Rect{Width: 20, Height: 4}) | |||
| 65 | + | require.True(t, ok) | |||
| 66 | + | require.Equal(t, []Placement{ | |||
| 67 | + | {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 68 | + | {ID: "b", Content: domain.Rect{Y: 2, Width: 20, Height: 1}, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, InStack: true}, | |||
| 69 | + | {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 70 | + | }, got) | |||
| 71 | + | ||||
| 72 | + | _, ok = Solve(stack, domain.Rect{Width: 20, Height: 3}) | |||
| 73 | + | require.False(t, ok) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestTooSmallRefusalsDoNotMutate(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | tests := []struct { | |||
| 79 | + | name string | |||
| 80 | + | op func(*Tree) error | |||
| 81 | + | }{ | |||
| 82 | + | { | |||
| 83 | + | name: "split refuses below min width", | |||
| 84 | + | op: func(tr *Tree) error { | |||
| 85 | + | return tr.Split("a", Right, true, "b", domain.Rect{Width: 40, Height: 2}) | |||
| 86 | + | }, | |||
| 87 | + | }, | |||
| 88 | + | { | |||
| 89 | + | name: "stack refuses overflow", | |||
| 90 | + | op: func(tr *Tree) error { | |||
| 91 | + | require.NoError(t, tr.StackNew("a", "b", domain.Rect{Width: 20, Height: 3})) | |||
| 92 | + | return tr.StackNew("b", "c", domain.Rect{Width: 20, Height: 3}) | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | for _, tt := range tests { | |||
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 99 | + | tr := NewTree("a") | |||
| 100 | + | before := tr.clone() | |||
| 101 | + | err := tt.op(tr) | |||
| 102 | + | require.ErrorIs(t, err, ErrTooSmall) | |||
| 103 | + | if tt.name == "stack refuses overflow" { | |||
| 104 | + | require.Equal(t, PaneID("b"), tr.Focus) | |||
| 105 | + | require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root)) | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | require.Equal(t, before, tr) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestTreeSurgeryShapes(t *testing.T) { | |||
| 114 | + | t.Parallel() | |||
| 115 | + | ||||
| 116 | + | t.Run("split preserves and dissolves containers", func(t *testing.T) { | |||
| 117 | + | tr := NewTree("a") | |||
| 118 | + | require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 2})) | |||
| 119 | + | require.Equal(t, Split, tr.Root.Kind) | |||
| 120 | + | require.Equal(t, Horizontal, tr.Root.Dir) | |||
| 121 | + | require.Equal(t, []PaneID{"a", "b"}, leafIDs(tr.Root)) | |||
| 122 | + | require.NoError(t, tr.Close("a")) | |||
| 123 | + | require.Equal(t, Leaf, tr.Root.Kind) | |||
| 124 | + | require.Equal(t, PaneID("b"), tr.Root.Leaf) | |||
| 125 | + | }) | |||
| 126 | + | ||||
| 127 | + | t.Run("nested split inserts on matching axis", func(t *testing.T) { | |||
| 128 | + | tr := NewTree("a") | |||
| 129 | + | require.NoError(t, tr.Split("a", Right, true, "b", domain.Rect{Width: 41, Height: 4})) | |||
| 130 | + | require.NoError(t, tr.Split("b", Right, true, "c", domain.Rect{Width: 62, Height: 4})) | |||
| 131 | + | require.Len(t, tr.Root.Children, 3) | |||
| 132 | + | require.Equal(t, []PaneID{"a", "b", "c"}, leafIDs(tr.Root)) | |||
| 133 | + | require.NoError(t, tr.Split("b", Down, true, "d", domain.Rect{Width: 62, Height: 5})) | |||
| 134 | + | require.Equal(t, Vertical, tr.Root.Children[1].Dir) | |||
| 135 | + | require.Equal(t, []PaneID{"a", "b", "d", "c"}, leafIDs(tr.Root)) | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestFocusDir(t *testing.T) { | |||
| 140 | + | t.Parallel() | |||
| 141 | + | area := domain.Rect{Width: 62, Height: 5} | |||
| 142 | + | tests := []struct { | |||
| 143 | + | name string | |||
| 144 | + | focus PaneID | |||
| 145 | + | dir Direction | |||
| 146 | + | want PaneID | |||
| 147 | + | }{ | |||
| 148 | + | {"right chooses overlapping neighbor", "a", Right, "b"}, | |||
| 149 | + | {"left chooses overlapping neighbor", "b", Left, "a"}, | |||
| 150 | + | {"down chooses vertical neighbor", "b", Down, "c"}, | |||
| 151 | + | {"up chooses vertical neighbor", "c", Up, "b"}, | |||
| 152 | + | } | |||
| 153 | + | for _, tt := range tests { | |||
| 154 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 155 | + | tr := &Tree{Focus: tt.focus, Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{ | |||
| 156 | + | NewLeaf("a"), | |||
| 157 | + | {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}}, | |||
| 158 | + | }}} | |||
| 159 | + | require.NoError(t, tr.FocusDir(tt.dir, area)) | |||
| 160 | + | require.Equal(t, tt.want, tr.Focus) | |||
| 161 | + | }) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | t.Run("stack local vertical focus expands member", func(t *testing.T) { | |||
| 165 | + | tr := &Tree{Focus: "b", Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}} | |||
| 166 | + | require.NoError(t, tr.FocusDir(Down, domain.Rect{Width: 20, Height: 4})) | |||
| 167 | + | require.Equal(t, PaneID("c"), tr.Focus) | |||
| 168 | + | require.Equal(t, PaneID("c"), tr.Root.Expanded) | |||
| 169 | + | require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4})) | |||
| 170 | + | require.Equal(t, PaneID("b"), tr.Focus) | |||
| 171 | + | }) | |||
| 172 | + | ||||
| 173 | + | t.Run("no pane in direction", func(t *testing.T) { | |||
| 174 | + | tr := NewTree("a") | |||
| 175 | + | err := tr.FocusDir(Left, domain.Rect{Width: 20, Height: 2}) | |||
| 176 | + | require.True(t, errors.Is(err, ErrNoPane)) | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func contents(ps []Placement) map[PaneID]domain.Rect { | |||
| 181 | + | out := make(map[PaneID]domain.Rect, len(ps)) | |||
| 182 | + | for _, p := range ps { | |||
| 183 | + | out[p.ID] = p.Content | |||
| 184 | + | } | |||
| 185 | + | return out | |||
| 186 | + | } | |||
A internal/usecase/layout/ops.go +378 -0
| @@ -0,0 +1,378 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sort" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | ErrNotFound = errors.New("pane not found") | |
| 12 | + | ErrTooSmall = errors.New("layout too small") | |
| 13 | + | ErrNoPane = errors.New("no pane in direction") | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Split adds newID next to target. The after flag controls whether the new pane | |
| 17 | + | // is placed after target on the chosen axis. | |
| 18 | + | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |
| 19 | + | candidate := t.clone() | |
| 20 | + | if !insertSplit(candidate.Root, target, axisFor(dir), after, newID) { | |
| 21 | + | return ErrNotFound | |
| 22 | + | } | |
| 23 | + | candidate.Focus = newID | |
| 24 | + | if _, ok := Solve(candidate.Root, area); !ok { | |
| 25 | + | return ErrTooSmall | |
| 26 | + | } | |
| 27 | + | *t = *candidate | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool { | |
| 32 | + | for i, child := range n.Children { | |
| 33 | + | if child.Kind == Leaf && child.Leaf == target { | |
| 34 | + | newLeaf := NewLeaf(newID) | |
| 35 | + | if n.Kind == Split && n.Dir == axis { | |
| 36 | + | at := i | |
| 37 | + | if after { | |
| 38 | + | at++ | |
| 39 | + | } | |
| 40 | + | n.Children = append(n.Children, nil) | |
| 41 | + | copy(n.Children[at+1:], n.Children[at:]) | |
| 42 | + | n.Children[at] = newLeaf | |
| 43 | + | return true | |
| 44 | + | } | |
| 45 | + | children := []*Node{child, newLeaf} | |
| 46 | + | if !after { | |
| 47 | + | children = []*Node{newLeaf, child} | |
| 48 | + | } | |
| 49 | + | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children} | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | if insertSplit(child, target, axis, after, newID) { | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if n.Kind == Leaf && n.Leaf == target { | |
| 57 | + | children := []*Node{n.clone(), NewLeaf(newID)} | |
| 58 | + | if !after { | |
| 59 | + | children = []*Node{NewLeaf(newID), n.clone()} | |
| 60 | + | } | |
| 61 | + | *n = Node{Kind: Split, Dir: axis, Children: children} | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // StackNew puts newID in target's stack, creating one if target is not stacked. | |
| 68 | + | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |
| 69 | + | candidate := t.clone() | |
| 70 | + | if !stackNew(candidate.Root, target, newID) { | |
| 71 | + | return ErrNotFound | |
| 72 | + | } | |
| 73 | + | candidate.Focus = newID | |
| 74 | + | if _, ok := Solve(candidate.Root, area); !ok { | |
| 75 | + | return ErrTooSmall | |
| 76 | + | } | |
| 77 | + | *t = *candidate | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func stackNew(n *Node, target, newID PaneID) bool { | |
| 82 | + | if n.Kind == Stack { | |
| 83 | + | for _, child := range n.Children { | |
| 84 | + | if child.Kind == Leaf && child.Leaf == target { | |
| 85 | + | n.Children = append(n.Children, NewLeaf(newID)) | |
| 86 | + | n.Expanded = newID | |
| 87 | + | return true | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | for i, child := range n.Children { | |
| 92 | + | if child.Kind == Leaf && child.Leaf == target { | |
| 93 | + | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID} | |
| 94 | + | return true | |
| 95 | + | } | |
| 96 | + | if stackNew(child, target, newID) { | |
| 97 | + | return true | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if n.Kind == Leaf && n.Leaf == target { | |
| 101 | + | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID} | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ToggleStack converts a stack back to a vertical split. For an unstacked leaf in | |
| 108 | + | // a two-child split, it converts that split to a stack. | |
| 109 | + | func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error { | |
| 110 | + | candidate := t.clone() | |
| 111 | + | if !toggleStack(candidate.Root, target) { | |
| 112 | + | return ErrNotFound | |
| 113 | + | } | |
| 114 | + | if _, ok := Solve(candidate.Root, area); !ok { | |
| 115 | + | return ErrTooSmall | |
| 116 | + | } | |
| 117 | + | *t = *candidate | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func toggleStack(n *Node, target PaneID) bool { | |
| 122 | + | if n.Kind == Stack && containsLeaf(n, target) { | |
| 123 | + | n.Kind = Split | |
| 124 | + | n.Dir = Vertical | |
| 125 | + | n.Expanded = "" | |
| 126 | + | return true | |
| 127 | + | } | |
| 128 | + | if n.Kind == Split && len(n.Children) == 2 && containsLeaf(n, target) { | |
| 129 | + | n.Kind = Stack | |
| 130 | + | n.Expanded = target | |
| 131 | + | return true | |
| 132 | + | } | |
| 133 | + | for _, child := range n.Children { | |
| 134 | + | if toggleStack(child, target) { | |
| 135 | + | return true | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Close removes target and dissolves single-child containers. | |
| 142 | + | func (t *Tree) Close(target PaneID) error { | |
| 143 | + | if t.Root == nil || !containsLeaf(t.Root, target) { | |
| 144 | + | return ErrNotFound | |
| 145 | + | } | |
| 146 | + | if t.Root.Kind == Leaf && t.Root.Leaf == target { | |
| 147 | + | t.Root = nil | |
| 148 | + | t.Focus = "" | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | root, removed := closeNode(t.Root, target) | |
| 152 | + | if !removed { | |
| 153 | + | return ErrNotFound | |
| 154 | + | } | |
| 155 | + | t.Root = root | |
| 156 | + | if t.Focus == target { | |
| 157 | + | ids := leafIDs(t.Root) | |
| 158 | + | if len(ids) > 0 { | |
| 159 | + | t.Focus = ids[0] | |
| 160 | + | } else { | |
| 161 | + | t.Focus = "" | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func closeNode(n *Node, target PaneID) (*Node, bool) { | |
| 168 | + | if n.Kind == Leaf { | |
| 169 | + | if n.Leaf == target { | |
| 170 | + | return nil, true | |
| 171 | + | } | |
| 172 | + | return n, false | |
| 173 | + | } | |
| 174 | + | removed := false | |
| 175 | + | children := n.Children[:0] | |
| 176 | + | for _, child := range n.Children { | |
| 177 | + | newChild, didRemove := closeNode(child, target) | |
| 178 | + | removed = removed || didRemove | |
| 179 | + | if newChild != nil { | |
| 180 | + | children = append(children, newChild) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | n.Children = children | |
| 184 | + | if !removed { | |
| 185 | + | return n, false | |
| 186 | + | } | |
| 187 | + | if n.Kind == Stack { | |
| 188 | + | if n.Expanded == target && len(n.Children) > 0 { | |
| 189 | + | n.Expanded = firstLeaf(n.Children[0]) | |
| 190 | + | } | |
| 191 | + | if len(n.Children) == 1 { | |
| 192 | + | return n.Children[0], true | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | if n.Kind == Split && len(n.Children) == 1 { | |
| 196 | + | return n.Children[0], true | |
| 197 | + | } | |
| 198 | + | return n, true | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func containsLeaf(n *Node, id PaneID) bool { | |
| 202 | + | if n == nil { | |
| 203 | + | return false | |
| 204 | + | } | |
| 205 | + | if n.Kind == Leaf { | |
| 206 | + | return n.Leaf == id | |
| 207 | + | } | |
| 208 | + | for _, child := range n.Children { | |
| 209 | + | if containsLeaf(child, id) { | |
| 210 | + | return true | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | return false | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func firstLeaf(n *Node) PaneID { | |
| 217 | + | if n.Kind == Leaf { | |
| 218 | + | return n.Leaf | |
| 219 | + | } | |
| 220 | + | for _, child := range n.Children { | |
| 221 | + | if id := firstLeaf(child); id != "" { | |
| 222 | + | return id | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | return "" | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func leafIDs(n *Node) []PaneID { | |
| 229 | + | if n == nil { | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | if n.Kind == Leaf { | |
| 233 | + | return []PaneID{n.Leaf} | |
| 234 | + | } | |
| 235 | + | var ids []PaneID | |
| 236 | + | for _, child := range n.Children { | |
| 237 | + | ids = append(ids, leafIDs(child)...) | |
| 238 | + | } | |
| 239 | + | return ids | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // FocusDir moves focus in dir. Vertical movement within a stack walks stack | |
| 243 | + | // members before falling back to geometric focus. | |
| 244 | + | func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |
| 245 | + | if t.Root == nil { | |
| 246 | + | return ErrNotFound | |
| 247 | + | } | |
| 248 | + | if (dir == Up || dir == Down) && focusStackLocal(t.Root, t.Focus, dir, &t.Focus) { | |
| 249 | + | return nil | |
| 250 | + | } | |
| 251 | + | placements, ok := Solve(t.Root, area) | |
| 252 | + | if !ok { | |
| 253 | + | return ErrTooSmall | |
| 254 | + | } | |
| 255 | + | currentIdx := -1 | |
| 256 | + | for i, p := range placements { | |
| 257 | + | if p.ID == t.Focus { | |
| 258 | + | currentIdx = i | |
| 259 | + | break | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | if currentIdx < 0 { | |
| 263 | + | return ErrNotFound | |
| 264 | + | } | |
| 265 | + | current := focusRect(placements[currentIdx]) | |
| 266 | + | type candidate struct { | |
| 267 | + | id PaneID | |
| 268 | + | overlap int | |
| 269 | + | gap int | |
| 270 | + | } | |
| 271 | + | var candidates []candidate | |
| 272 | + | for _, p := range placements { | |
| 273 | + | if p.ID == t.Focus { | |
| 274 | + | continue | |
| 275 | + | } | |
| 276 | + | r := focusRect(p) | |
| 277 | + | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | |
| 278 | + | candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap}) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | if len(candidates) == 0 { | |
| 282 | + | return ErrNoPane | |
| 283 | + | } | |
| 284 | + | sort.Slice(candidates, func(i, j int) bool { | |
| 285 | + | if candidates[i].overlap != candidates[j].overlap { | |
| 286 | + | return candidates[i].overlap > candidates[j].overlap | |
| 287 | + | } | |
| 288 | + | if candidates[i].gap != candidates[j].gap { | |
| 289 | + | return candidates[i].gap < candidates[j].gap | |
| 290 | + | } | |
| 291 | + | return candidates[i].id < candidates[j].id | |
| 292 | + | }) | |
| 293 | + | t.Focus = candidates[0].id | |
| 294 | + | return nil | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |
| 298 | + | if n.Kind == Stack { | |
| 299 | + | idx := -1 | |
| 300 | + | for i, child := range n.Children { | |
| 301 | + | if child.Kind == Leaf && child.Leaf == id { | |
| 302 | + | idx = i | |
| 303 | + | break | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | if idx >= 0 { | |
| 307 | + | next := idx | |
| 308 | + | if dir == Up && idx > 0 { | |
| 309 | + | next = idx - 1 | |
| 310 | + | } | |
| 311 | + | if dir == Down && idx < len(n.Children)-1 { | |
| 312 | + | next = idx + 1 | |
| 313 | + | } | |
| 314 | + | if next != idx { | |
| 315 | + | n.Expanded = n.Children[next].Leaf | |
| 316 | + | *focus = n.Children[next].Leaf | |
| 317 | + | return true | |
| 318 | + | } | |
| 319 | + | return false | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | for _, child := range n.Children { | |
| 323 | + | if focusStackLocal(child, id, dir, focus) { | |
| 324 | + | return true | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func focusRect(p Placement) domain.Rect { | |
| 331 | + | if p.TitleBar.Height == 0 { | |
| 332 | + | return p.Content | |
| 333 | + | } | |
| 334 | + | if p.Content.Height == 0 { | |
| 335 | + | return p.TitleBar | |
| 336 | + | } | |
| 337 | + | y := p.TitleBar.Y | |
| 338 | + | height := p.Content.Y + p.Content.Height - y | |
| 339 | + | return domain.Rect{X: p.TitleBar.X, Y: y, Width: p.TitleBar.Width, Height: height} | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func sideGapOverlap(a, b domain.Rect, dir Direction) (bool, int, int) { | |
| 343 | + | ar, ab := a.X+a.Width, a.Y+a.Height | |
| 344 | + | br, bb := b.X+b.Width, b.Y+b.Height | |
| 345 | + | switch dir { | |
| 346 | + | case Left: | |
| 347 | + | if br > a.X { | |
| 348 | + | return false, 0, 0 | |
| 349 | + | } | |
| 350 | + | return true, a.X - br, overlap(a.Y, ab, b.Y, bb) | |
| 351 | + | case Right: | |
| 352 | + | if b.X < ar { | |
| 353 | + | return false, 0, 0 | |
| 354 | + | } | |
| 355 | + | return true, b.X - ar, overlap(a.Y, ab, b.Y, bb) | |
| 356 | + | case Up: | |
| 357 | + | if bb > a.Y { | |
| 358 | + | return false, 0, 0 | |
| 359 | + | } | |
| 360 | + | return true, a.Y - bb, overlap(a.X, ar, b.X, br) | |
| 361 | + | case Down: | |
| 362 | + | if b.Y < ab { | |
| 363 | + | return false, 0, 0 | |
| 364 | + | } | |
| 365 | + | return true, b.Y - ab, overlap(a.X, ar, b.X, br) | |
| 366 | + | default: | |
| 367 | + | return false, 0, 0 | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func overlap(a0, a1, b0, b1 int) int { | |
| 372 | + | lo := max(a0, b0) | |
| 373 | + | hi := min(a1, b1) | |
| 374 | + | if hi <= lo { | |
| 375 | + | return 0 | |
| 376 | + | } | |
| 377 | + | return hi - lo | |
| 378 | + | } | |
| @@ -0,0 +1,378 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "sort" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | ErrNotFound = errors.New("pane not found") | |||
| 12 | + | ErrTooSmall = errors.New("layout too small") | |||
| 13 | + | ErrNoPane = errors.New("no pane in direction") | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Split adds newID next to target. The after flag controls whether the new pane | |||
| 17 | + | // is placed after target on the chosen axis. | |||
| 18 | + | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |||
| 19 | + | candidate := t.clone() | |||
| 20 | + | if !insertSplit(candidate.Root, target, axisFor(dir), after, newID) { | |||
| 21 | + | return ErrNotFound | |||
| 22 | + | } | |||
| 23 | + | candidate.Focus = newID | |||
| 24 | + | if _, ok := Solve(candidate.Root, area); !ok { | |||
| 25 | + | return ErrTooSmall | |||
| 26 | + | } | |||
| 27 | + | *t = *candidate | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool { | |||
| 32 | + | for i, child := range n.Children { | |||
| 33 | + | if child.Kind == Leaf && child.Leaf == target { | |||
| 34 | + | newLeaf := NewLeaf(newID) | |||
| 35 | + | if n.Kind == Split && n.Dir == axis { | |||
| 36 | + | at := i | |||
| 37 | + | if after { | |||
| 38 | + | at++ | |||
| 39 | + | } | |||
| 40 | + | n.Children = append(n.Children, nil) | |||
| 41 | + | copy(n.Children[at+1:], n.Children[at:]) | |||
| 42 | + | n.Children[at] = newLeaf | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | children := []*Node{child, newLeaf} | |||
| 46 | + | if !after { | |||
| 47 | + | children = []*Node{newLeaf, child} | |||
| 48 | + | } | |||
| 49 | + | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children} | |||
| 50 | + | return true | |||
| 51 | + | } | |||
| 52 | + | if insertSplit(child, target, axis, after, newID) { | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if n.Kind == Leaf && n.Leaf == target { | |||
| 57 | + | children := []*Node{n.clone(), NewLeaf(newID)} | |||
| 58 | + | if !after { | |||
| 59 | + | children = []*Node{NewLeaf(newID), n.clone()} | |||
| 60 | + | } | |||
| 61 | + | *n = Node{Kind: Split, Dir: axis, Children: children} | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // StackNew puts newID in target's stack, creating one if target is not stacked. | |||
| 68 | + | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |||
| 69 | + | candidate := t.clone() | |||
| 70 | + | if !stackNew(candidate.Root, target, newID) { | |||
| 71 | + | return ErrNotFound | |||
| 72 | + | } | |||
| 73 | + | candidate.Focus = newID | |||
| 74 | + | if _, ok := Solve(candidate.Root, area); !ok { | |||
| 75 | + | return ErrTooSmall | |||
| 76 | + | } | |||
| 77 | + | *t = *candidate | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func stackNew(n *Node, target, newID PaneID) bool { | |||
| 82 | + | if n.Kind == Stack { | |||
| 83 | + | for _, child := range n.Children { | |||
| 84 | + | if child.Kind == Leaf && child.Leaf == target { | |||
| 85 | + | n.Children = append(n.Children, NewLeaf(newID)) | |||
| 86 | + | n.Expanded = newID | |||
| 87 | + | return true | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | for i, child := range n.Children { | |||
| 92 | + | if child.Kind == Leaf && child.Leaf == target { | |||
| 93 | + | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID} | |||
| 94 | + | return true | |||
| 95 | + | } | |||
| 96 | + | if stackNew(child, target, newID) { | |||
| 97 | + | return true | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | if n.Kind == Leaf && n.Leaf == target { | |||
| 101 | + | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID} | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // ToggleStack converts a stack back to a vertical split. For an unstacked leaf in | |||
| 108 | + | // a two-child split, it converts that split to a stack. | |||
| 109 | + | func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error { | |||
| 110 | + | candidate := t.clone() | |||
| 111 | + | if !toggleStack(candidate.Root, target) { | |||
| 112 | + | return ErrNotFound | |||
| 113 | + | } | |||
| 114 | + | if _, ok := Solve(candidate.Root, area); !ok { | |||
| 115 | + | return ErrTooSmall | |||
| 116 | + | } | |||
| 117 | + | *t = *candidate | |||
| 118 | + | return nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func toggleStack(n *Node, target PaneID) bool { | |||
| 122 | + | if n.Kind == Stack && containsLeaf(n, target) { | |||
| 123 | + | n.Kind = Split | |||
| 124 | + | n.Dir = Vertical | |||
| 125 | + | n.Expanded = "" | |||
| 126 | + | return true | |||
| 127 | + | } | |||
| 128 | + | if n.Kind == Split && len(n.Children) == 2 && containsLeaf(n, target) { | |||
| 129 | + | n.Kind = Stack | |||
| 130 | + | n.Expanded = target | |||
| 131 | + | return true | |||
| 132 | + | } | |||
| 133 | + | for _, child := range n.Children { | |||
| 134 | + | if toggleStack(child, target) { | |||
| 135 | + | return true | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return false | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Close removes target and dissolves single-child containers. | |||
| 142 | + | func (t *Tree) Close(target PaneID) error { | |||
| 143 | + | if t.Root == nil || !containsLeaf(t.Root, target) { | |||
| 144 | + | return ErrNotFound | |||
| 145 | + | } | |||
| 146 | + | if t.Root.Kind == Leaf && t.Root.Leaf == target { | |||
| 147 | + | t.Root = nil | |||
| 148 | + | t.Focus = "" | |||
| 149 | + | return nil | |||
| 150 | + | } | |||
| 151 | + | root, removed := closeNode(t.Root, target) | |||
| 152 | + | if !removed { | |||
| 153 | + | return ErrNotFound | |||
| 154 | + | } | |||
| 155 | + | t.Root = root | |||
| 156 | + | if t.Focus == target { | |||
| 157 | + | ids := leafIDs(t.Root) | |||
| 158 | + | if len(ids) > 0 { | |||
| 159 | + | t.Focus = ids[0] | |||
| 160 | + | } else { | |||
| 161 | + | t.Focus = "" | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func closeNode(n *Node, target PaneID) (*Node, bool) { | |||
| 168 | + | if n.Kind == Leaf { | |||
| 169 | + | if n.Leaf == target { | |||
| 170 | + | return nil, true | |||
| 171 | + | } | |||
| 172 | + | return n, false | |||
| 173 | + | } | |||
| 174 | + | removed := false | |||
| 175 | + | children := n.Children[:0] | |||
| 176 | + | for _, child := range n.Children { | |||
| 177 | + | newChild, didRemove := closeNode(child, target) | |||
| 178 | + | removed = removed || didRemove | |||
| 179 | + | if newChild != nil { | |||
| 180 | + | children = append(children, newChild) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | n.Children = children | |||
| 184 | + | if !removed { | |||
| 185 | + | return n, false | |||
| 186 | + | } | |||
| 187 | + | if n.Kind == Stack { | |||
| 188 | + | if n.Expanded == target && len(n.Children) > 0 { | |||
| 189 | + | n.Expanded = firstLeaf(n.Children[0]) | |||
| 190 | + | } | |||
| 191 | + | if len(n.Children) == 1 { | |||
| 192 | + | return n.Children[0], true | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | if n.Kind == Split && len(n.Children) == 1 { | |||
| 196 | + | return n.Children[0], true | |||
| 197 | + | } | |||
| 198 | + | return n, true | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func containsLeaf(n *Node, id PaneID) bool { | |||
| 202 | + | if n == nil { | |||
| 203 | + | return false | |||
| 204 | + | } | |||
| 205 | + | if n.Kind == Leaf { | |||
| 206 | + | return n.Leaf == id | |||
| 207 | + | } | |||
| 208 | + | for _, child := range n.Children { | |||
| 209 | + | if containsLeaf(child, id) { | |||
| 210 | + | return true | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | return false | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func firstLeaf(n *Node) PaneID { | |||
| 217 | + | if n.Kind == Leaf { | |||
| 218 | + | return n.Leaf | |||
| 219 | + | } | |||
| 220 | + | for _, child := range n.Children { | |||
| 221 | + | if id := firstLeaf(child); id != "" { | |||
| 222 | + | return id | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | return "" | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func leafIDs(n *Node) []PaneID { | |||
| 229 | + | if n == nil { | |||
| 230 | + | return nil | |||
| 231 | + | } | |||
| 232 | + | if n.Kind == Leaf { | |||
| 233 | + | return []PaneID{n.Leaf} | |||
| 234 | + | } | |||
| 235 | + | var ids []PaneID | |||
| 236 | + | for _, child := range n.Children { | |||
| 237 | + | ids = append(ids, leafIDs(child)...) | |||
| 238 | + | } | |||
| 239 | + | return ids | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // FocusDir moves focus in dir. Vertical movement within a stack walks stack | |||
| 243 | + | // members before falling back to geometric focus. | |||
| 244 | + | func (t *Tree) FocusDir(dir Direction, area domain.Rect) error { | |||
| 245 | + | if t.Root == nil { | |||
| 246 | + | return ErrNotFound | |||
| 247 | + | } | |||
| 248 | + | if (dir == Up || dir == Down) && focusStackLocal(t.Root, t.Focus, dir, &t.Focus) { | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | placements, ok := Solve(t.Root, area) | |||
| 252 | + | if !ok { | |||
| 253 | + | return ErrTooSmall | |||
| 254 | + | } | |||
| 255 | + | currentIdx := -1 | |||
| 256 | + | for i, p := range placements { | |||
| 257 | + | if p.ID == t.Focus { | |||
| 258 | + | currentIdx = i | |||
| 259 | + | break | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | if currentIdx < 0 { | |||
| 263 | + | return ErrNotFound | |||
| 264 | + | } | |||
| 265 | + | current := focusRect(placements[currentIdx]) | |||
| 266 | + | type candidate struct { | |||
| 267 | + | id PaneID | |||
| 268 | + | overlap int | |||
| 269 | + | gap int | |||
| 270 | + | } | |||
| 271 | + | var candidates []candidate | |||
| 272 | + | for _, p := range placements { | |||
| 273 | + | if p.ID == t.Focus { | |||
| 274 | + | continue | |||
| 275 | + | } | |||
| 276 | + | r := focusRect(p) | |||
| 277 | + | if ok, gap, overlap := sideGapOverlap(current, r, dir); ok { | |||
| 278 | + | candidates = append(candidates, candidate{id: p.ID, gap: gap, overlap: overlap}) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | if len(candidates) == 0 { | |||
| 282 | + | return ErrNoPane | |||
| 283 | + | } | |||
| 284 | + | sort.Slice(candidates, func(i, j int) bool { | |||
| 285 | + | if candidates[i].overlap != candidates[j].overlap { | |||
| 286 | + | return candidates[i].overlap > candidates[j].overlap | |||
| 287 | + | } | |||
| 288 | + | if candidates[i].gap != candidates[j].gap { | |||
| 289 | + | return candidates[i].gap < candidates[j].gap | |||
| 290 | + | } | |||
| 291 | + | return candidates[i].id < candidates[j].id | |||
| 292 | + | }) | |||
| 293 | + | t.Focus = candidates[0].id | |||
| 294 | + | return nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func focusStackLocal(n *Node, id PaneID, dir Direction, focus *PaneID) bool { | |||
| 298 | + | if n.Kind == Stack { | |||
| 299 | + | idx := -1 | |||
| 300 | + | for i, child := range n.Children { | |||
| 301 | + | if child.Kind == Leaf && child.Leaf == id { | |||
| 302 | + | idx = i | |||
| 303 | + | break | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | if idx >= 0 { | |||
| 307 | + | next := idx | |||
| 308 | + | if dir == Up && idx > 0 { | |||
| 309 | + | next = idx - 1 | |||
| 310 | + | } | |||
| 311 | + | if dir == Down && idx < len(n.Children)-1 { | |||
| 312 | + | next = idx + 1 | |||
| 313 | + | } | |||
| 314 | + | if next != idx { | |||
| 315 | + | n.Expanded = n.Children[next].Leaf | |||
| 316 | + | *focus = n.Children[next].Leaf | |||
| 317 | + | return true | |||
| 318 | + | } | |||
| 319 | + | return false | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | for _, child := range n.Children { | |||
| 323 | + | if focusStackLocal(child, id, dir, focus) { | |||
| 324 | + | return true | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func focusRect(p Placement) domain.Rect { | |||
| 331 | + | if p.TitleBar.Height == 0 { | |||
| 332 | + | return p.Content | |||
| 333 | + | } | |||
| 334 | + | if p.Content.Height == 0 { | |||
| 335 | + | return p.TitleBar | |||
| 336 | + | } | |||
| 337 | + | y := p.TitleBar.Y | |||
| 338 | + | height := p.Content.Y + p.Content.Height - y | |||
| 339 | + | return domain.Rect{X: p.TitleBar.X, Y: y, Width: p.TitleBar.Width, Height: height} | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func sideGapOverlap(a, b domain.Rect, dir Direction) (bool, int, int) { | |||
| 343 | + | ar, ab := a.X+a.Width, a.Y+a.Height | |||
| 344 | + | br, bb := b.X+b.Width, b.Y+b.Height | |||
| 345 | + | switch dir { | |||
| 346 | + | case Left: | |||
| 347 | + | if br > a.X { | |||
| 348 | + | return false, 0, 0 | |||
| 349 | + | } | |||
| 350 | + | return true, a.X - br, overlap(a.Y, ab, b.Y, bb) | |||
| 351 | + | case Right: | |||
| 352 | + | if b.X < ar { | |||
| 353 | + | return false, 0, 0 | |||
| 354 | + | } | |||
| 355 | + | return true, b.X - ar, overlap(a.Y, ab, b.Y, bb) | |||
| 356 | + | case Up: | |||
| 357 | + | if bb > a.Y { | |||
| 358 | + | return false, 0, 0 | |||
| 359 | + | } | |||
| 360 | + | return true, a.Y - bb, overlap(a.X, ar, b.X, br) | |||
| 361 | + | case Down: | |||
| 362 | + | if b.Y < ab { | |||
| 363 | + | return false, 0, 0 | |||
| 364 | + | } | |||
| 365 | + | return true, b.Y - ab, overlap(a.X, ar, b.X, br) | |||
| 366 | + | default: | |||
| 367 | + | return false, 0, 0 | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func overlap(a0, a1, b0, b1 int) int { | |||
| 372 | + | lo := max(a0, b0) | |||
| 373 | + | hi := min(a1, b1) | |||
| 374 | + | if hi <= lo { | |||
| 375 | + | return 0 | |||
| 376 | + | } | |||
| 377 | + | return hi - lo | |||
| 378 | + | } | |||
A internal/usecase/layout/tree.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 5 | + | // PaneID is an opaque pane identifier owned by higher layers. | |
| 6 | + | type PaneID string | |
| 7 | + | ||
| 8 | + | // Kind identifies the topology role of a Node. | |
| 9 | + | type Kind int | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | Leaf Kind = iota | |
| 13 | + | Split | |
| 14 | + | Stack | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Direction is used for split and focus operations. | |
| 18 | + | type Direction int | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | Left Direction = iota | |
| 22 | + | Right | |
| 23 | + | Up | |
| 24 | + | Down | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // SplitDir is the axis used by a split container. | |
| 28 | + | type SplitDir int | |
| 29 | + | ||
| 30 | + | const ( | |
| 31 | + | Horizontal SplitDir = iota // children are laid out left-to-right | |
| 32 | + | Vertical // children are laid out top-to-bottom | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | const ( | |
| 36 | + | MinPaneCols = 20 | |
| 37 | + | MinPaneRows = 2 | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | |
| 41 | + | // and Children are meaningful for Split nodes; Children and Expanded are | |
| 42 | + | // meaningful for Stack nodes. | |
| 43 | + | type Node struct { | |
| 44 | + | Kind Kind | |
| 45 | + | Dir SplitDir | |
| 46 | + | Children []*Node | |
| 47 | + | Leaf PaneID | |
| 48 | + | Expanded PaneID | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Tree is a pane layout plus its focused pane. | |
| 52 | + | type Tree struct { | |
| 53 | + | Root *Node | |
| 54 | + | Focus PaneID | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Placement is the solved geometry for a pane. | |
| 58 | + | type Placement struct { | |
| 59 | + | ID PaneID | |
| 60 | + | Content domain.Rect | |
| 61 | + | TitleBar domain.Rect | |
| 62 | + | Collapsed bool | |
| 63 | + | InStack bool | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func NewLeaf(id PaneID) *Node { return &Node{Kind: Leaf, Leaf: id} } | |
| 67 | + | ||
| 68 | + | func NewTree(id PaneID) *Tree { return &Tree{Root: NewLeaf(id), Focus: id} } | |
| 69 | + | ||
| 70 | + | func axisFor(dir Direction) SplitDir { | |
| 71 | + | if dir == Left || dir == Right { | |
| 72 | + | return Horizontal | |
| 73 | + | } | |
| 74 | + | return Vertical | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (n *Node) clone() *Node { | |
| 78 | + | if n == nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | out := *n | |
| 82 | + | if len(n.Children) > 0 { | |
| 83 | + | out.Children = make([]*Node, len(n.Children)) | |
| 84 | + | for i, child := range n.Children { | |
| 85 | + | out.Children[i] = child.clone() | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return &out | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (t *Tree) clone() *Tree { | |
| 92 | + | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | // PaneID is an opaque pane identifier owned by higher layers. | |||
| 6 | + | type PaneID string | |||
| 7 | + | ||||
| 8 | + | // Kind identifies the topology role of a Node. | |||
| 9 | + | type Kind int | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | Leaf Kind = iota | |||
| 13 | + | Split | |||
| 14 | + | Stack | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Direction is used for split and focus operations. | |||
| 18 | + | type Direction int | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | Left Direction = iota | |||
| 22 | + | Right | |||
| 23 | + | Up | |||
| 24 | + | Down | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // SplitDir is the axis used by a split container. | |||
| 28 | + | type SplitDir int | |||
| 29 | + | ||||
| 30 | + | const ( | |||
| 31 | + | Horizontal SplitDir = iota // children are laid out left-to-right | |||
| 32 | + | Vertical // children are laid out top-to-bottom | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | const ( | |||
| 36 | + | MinPaneCols = 20 | |||
| 37 | + | MinPaneRows = 2 | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | |||
| 41 | + | // and Children are meaningful for Split nodes; Children and Expanded are | |||
| 42 | + | // meaningful for Stack nodes. | |||
| 43 | + | type Node struct { | |||
| 44 | + | Kind Kind | |||
| 45 | + | Dir SplitDir | |||
| 46 | + | Children []*Node | |||
| 47 | + | Leaf PaneID | |||
| 48 | + | Expanded PaneID | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Tree is a pane layout plus its focused pane. | |||
| 52 | + | type Tree struct { | |||
| 53 | + | Root *Node | |||
| 54 | + | Focus PaneID | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Placement is the solved geometry for a pane. | |||
| 58 | + | type Placement struct { | |||
| 59 | + | ID PaneID | |||
| 60 | + | Content domain.Rect | |||
| 61 | + | TitleBar domain.Rect | |||
| 62 | + | Collapsed bool | |||
| 63 | + | InStack bool | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func NewLeaf(id PaneID) *Node { return &Node{Kind: Leaf, Leaf: id} } | |||
| 67 | + | ||||
| 68 | + | func NewTree(id PaneID) *Tree { return &Tree{Root: NewLeaf(id), Focus: id} } | |||
| 69 | + | ||||
| 70 | + | func axisFor(dir Direction) SplitDir { | |||
| 71 | + | if dir == Left || dir == Right { | |||
| 72 | + | return Horizontal | |||
| 73 | + | } | |||
| 74 | + | return Vertical | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (n *Node) clone() *Node { | |||
| 78 | + | if n == nil { | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | out := *n | |||
| 82 | + | if len(n.Children) > 0 { | |||
| 83 | + | out.Children = make([]*Node, len(n.Children)) | |||
| 84 | + | for i, child := range n.Children { | |||
| 85 | + | out.Children[i] = child.clone() | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | return &out | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (t *Tree) clone() *Tree { | |||
| 92 | + | return &Tree{Root: t.Root.clone(), Focus: t.Focus} | |||
| 93 | + | } | |||
M internal/usecase/theme/theme.go +18 -1
| @@ -184,7 +184,7 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | 186 | func findTerminator(data []byte) (start int, end int) { | |
| 187 | - | for i := 0; i < len(data); i++ { | |
| 187 | + | for i := range data { | |
| 188 | 188 | switch data[i] { | |
| 189 | 189 | case '\x07': | |
| 190 | 190 | return i, i + 1 | |
| @@ -250,6 +250,23 @@ func BorderStyle(t Theme) renderer.Style { | |||
| 250 | 250 | return style | |
| 251 | 251 | } | |
| 252 | 252 | ||
| 253 | + | func DimStyle(style renderer.Style, t Theme) renderer.Style { | |
| 254 | + | if !usable(t) { | |
| 255 | + | return style | |
| 256 | + | } | |
| 257 | + | out := style | |
| 258 | + | out.HasForegroundRGB = true | |
| 259 | + | if style.HasForegroundRGB { | |
| 260 | + | out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35) | |
| 261 | + | } else { | |
| 262 | + | out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35) | |
| 263 | + | } | |
| 264 | + | if style.HasBackgroundRGB { | |
| 265 | + | out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35) | |
| 266 | + | } | |
| 267 | + | return out | |
| 268 | + | } | |
| 269 | + | ||
| 253 | 270 | func SelectionStyle(t Theme) renderer.Style { | |
| 254 | 271 | if !usable(t) { | |
| 255 | 272 | return inverseStyle() | |
| @@ -184,7 +184,7 @@ func colorOSCPrefix(data []byte) (complete bool, possible bool) { | |||||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | func findTerminator(data []byte) (start int, end int) { | 186 | func findTerminator(data []byte) (start int, end int) { | ||
| 187 | - | for i := 0; i < len(data); i++ { | 187 | + | for i := range data { |
| 188 | switch data[i] { | 188 | switch data[i] { | ||
| 189 | case '\x07': | 189 | case '\x07': | ||
| 190 | return i, i + 1 | 190 | return i, i + 1 | ||
| @@ -250,6 +250,23 @@ func BorderStyle(t Theme) renderer.Style { | |||||
| 250 | return style | 250 | return style | ||
| 251 | } | 251 | } | ||
| 252 | 252 | ||||
| 253 | + | func DimStyle(style renderer.Style, t Theme) renderer.Style { | |||
| 254 | + | if !usable(t) { | |||
| 255 | + | return style | |||
| 256 | + | } | |||
| 257 | + | out := style | |||
| 258 | + | out.HasForegroundRGB = true | |||
| 259 | + | if style.HasForegroundRGB { | |||
| 260 | + | out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35) | |||
| 261 | + | } else { | |||
| 262 | + | out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35) | |||
| 263 | + | } | |||
| 264 | + | if style.HasBackgroundRGB { | |||
| 265 | + | out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35) | |||
| 266 | + | } | |||
| 267 | + | return out | |||
| 268 | + | } | |||
| 269 | + | ||||
| 253 | func SelectionStyle(t Theme) renderer.Style { | 270 | func SelectionStyle(t Theme) renderer.Style { | ||
| 254 | if !usable(t) { | 271 | if !usable(t) { | ||
| 255 | return inverseStyle() | 272 | return inverseStyle() | ||