detached 181ce78

feat(panes): add split and stacked panes

commit
181ce78d68ef6fd53acb6b157d2335f52771e0c3
author
brice · · 2026-07-04 03:45 UTC
parent
56bc502
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
161161 return p.cmd.Process.Pid
162162 }
163163
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+
164184 // Close terminates the child and releases the master. It signals the child's
165185 // process group with SIGHUP, waits up to killGracePeriod for it to exit, then
166186 // 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.Pid161 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's184 // 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, then185 // 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) {
9898 require.NoError(t, p.Close())
9999 }
100100
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+
101115 func TestResize_SttySize(t *testing.T) {
102116 if testing.Short() {
103117 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
33 import (
44 "fmt"
55 "os"
6+ "strings"
67 )
78
89 // ProcessCwd returns the current working directory for a Linux process.
910 func ProcessCwd(pid int) (string, error) {
1011 return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
1112 }
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
8484 return _c
8585 }
8686
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+
87140 // Pid provides a mock function for the type MockPTY
88141 func (_mock *MockPTY) Pid() int {
89142 ret := _mock.Called()
@@ -84,6 +84,59 @@ func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call
84 return _c84 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 MockPTY140 // 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 {
1212 io.ReadWriteCloser // Read = child output, Write = child input
1313 Resize(sz domain.Size) error
1414 Pid() int
15+ ForegroundPgid() (int, error)
1516 }
1617
1718 // 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 input12 io.ReadWriteCloser // Read = child output, Write = child input
13 Resize(sz domain.Size) error13 Resize(sz domain.Size) error
14 Pid() int14 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 {
55 CreateTab() error
66 CreateSession() error
77 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
819 NextTab() error
920 PrevTab() error
1021 Detach() error
@@ -5,6 +5,17 @@ type Context interface {
5 CreateTab() error5 CreateTab() error
6 CreateSession() error6 CreateSession() error
7 CloseTab() error7 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() error19 NextTab() error
9 PrevTab() error20 PrevTab() error
10 Detach() error21 Detach() error
M internal/usecase/command/registry.go +11 -0
@@ -8,6 +8,17 @@ func Registry() []Command {
88 {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }},
99 {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }},
1010 {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() }},
1122 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
1223 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
1324 {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 (
88
99 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
1010 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"}
1212
1313 if len(commands) != len(wantCodes) {
1414 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -54,6 +54,17 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
5454 {code: "CNT", want: "CreateTab"},
5555 {code: "CNS", want: "CreateSession"},
5656 {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"},
5768 {code: "NXT", want: "NextTab"},
5869 {code: "PVT", want: "PrevTab"},
5970 {code: "SSP", want: "OpenSessionPicker"},
@@ -99,6 +110,17 @@ func (s *spyContext) onlyCall() string {
99110 func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
100111 func (s *spyContext) CreateSession() error { return s.record("CreateSession") }
101112 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") }
102124 func (s *spyContext) NextTab() error { return s.record("NextTab") }
103125 func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
104126 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 {
156156 p.once.Do(func() { close(p.closed) })
157157 return nil
158158 }
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 }
161162
162163 func TestAckAttentionClearsOnlyPaintedVisibleTab(t *testing.T) {
163164 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 nil157 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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -254,8 +253,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
254253 sess.mu.Unlock()
255254 for _, tb := range tabs {
256255 tb.mu.Lock()
257- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
256+ panes := tb.panesSnapshot()
258257 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+ }
259263 }
260264 return ac, old
261265 }
@@ -281,8 +285,13 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
281285 sess.mu.Unlock()
282286 for _, tb := range tabs {
283287 tb.mu.Lock()
284- tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
288+ panes := tb.panesSnapshot()
285289 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+ }
286295 }
287296 }
288297
@@ -410,8 +419,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
410419 ac.setTheme(t)
411420 for _, tb := range tabs {
412421 tb.mu.Lock()
413- tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
422+ panes := tb.panesSnapshot()
414423 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+ }
415429 }
416430 d.paint(sess, ac, true)
417431 }
@@ -421,7 +435,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
421435 return
422436 }
423437 tbSize := tabSize(sz)
424- ac.size = sz
438+ if ac != nil {
439+ ac.size = sz
440+ }
425441
426442 sess.mu.Lock()
427443 tabs := append([]*tab(nil), sess.tabs...)
@@ -431,15 +447,27 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
431447 }
432448
433449 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- }
437450 tb.mu.Lock()
438- tb.screen.Resize(tbSize.Cols, tbSize.Rows)
439451 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+ }
440466 tb.mu.Unlock()
441467 }
442- d.paint(sess, ac, true)
468+ if ac != nil {
469+ d.paint(sess, ac, true)
470+ }
443471 }
444472
445473 // detachOnSendError drops a client whose transport failed. Like every other
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 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, old264 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 return435 return
422 }436 }
423 tbSize := tabSize(sz)437 tbSize := tabSize(sz)
424- ac.size = sz438+ 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 = tbSize451 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 other473 // detachOnSendError drops a client whose transport failed. Like every other
M internal/usecase/daemon/copymode.go +61 -23
@@ -17,20 +17,21 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
2928 "strconv"
3029
30+ "github.com/bnema/vev/internal/domain"
3131 "github.com/bnema/vev/internal/ports"
3232 scopy "github.com/bnema/vev/internal/usecase/copy"
3333 "github.com/bnema/vev/internal/usecase/keys"
34+ "github.com/bnema/vev/internal/usecase/layout"
3435 "github.com/bnema/vev/internal/usecase/mouse"
3536 "github.com/bnema/vev/pkg/renderer"
3637 )
@@ -42,17 +43,23 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
4243 }
4344
4445 tb.mu.Lock()
46+ p := tb.focusedPane()
47+ tb.mu.Unlock()
48+ if p == nil {
49+ return
50+ }
51+ p.mu.Lock()
4552 ac.copyMu.Lock()
4653 if ac.copyMode == nil {
4754 ac.copyMu.Unlock()
48- tb.mu.Unlock()
55+ p.mu.Unlock()
4956 return
5057 }
51- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
58+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
5259 if delta > 0 && ac.copyMode.AtBottom(snap) {
5360 ac.copyMode = nil
5461 ac.copyMu.Unlock()
55- tb.mu.Unlock()
62+ p.mu.Unlock()
5663 d.paint(sess, ac, true)
5764 return
5865 }
@@ -62,7 +69,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6269 ac.copyMode = nil
6370 }
6471 ac.copyMu.Unlock()
65- tb.mu.Unlock()
72+ p.mu.Unlock()
6673
6774 d.paint(sess, ac, true)
6875 }
@@ -73,8 +80,14 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
7380 return
7481 }
7582 tb.mu.Lock()
76- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
83+ p := tb.focusedPane()
7784 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()
7891 ac.copyMu.Lock()
7992 ac.copyMode = scopy.NewMode(snap)
8093 ac.copyPressRowValid = false
@@ -94,23 +107,29 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
94107 }
95108
96109 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 {
98117 if ev.Type == mouse.Press {
99118 ac.copyMu.Lock()
100119 ac.copyPressRowValid = false
101120 ac.copyDragging = false
102121 ac.copyMu.Unlock()
103122 }
104- tb.mu.Unlock()
123+ p.mu.Unlock()
105124 return
106125 }
107126 ac.copyMu.Lock()
108127 if ac.copyMode == nil {
109128 ac.copyMu.Unlock()
110- tb.mu.Unlock()
129+ p.mu.Unlock()
111130 return
112131 }
113- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
132+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
114133 absRow := ac.copyMode.ViewportTop + ev.Row
115134 changed := false
116135 switch ev.Type {
@@ -134,7 +153,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
134153 // Button release intentionally has no visual effect.
135154 }
136155 ac.copyMu.Unlock()
137- tb.mu.Unlock()
156+ p.mu.Unlock()
138157
139158 if changed {
140159 d.paint(sess, ac, true)
@@ -152,12 +171,18 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
152171 }
153172
154173 tb.mu.Lock()
174+ p := tb.focusedPane()
175+ tb.mu.Unlock()
176+ if p == nil {
177+ return
178+ }
179+ p.mu.Lock()
155180 ac.copyMu.Lock()
156181 if ac.copyMode == nil {
157182 ac.copyPending = nil
158183 d.stopCopyPendingTimerLocked(ac)
159184 ac.copyMu.Unlock()
160- tb.mu.Unlock()
185+ p.mu.Unlock()
161186 return
162187 }
163188 if len(ac.copyPending) > 0 {
@@ -168,7 +193,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
168193 data = combined
169194 ac.copyPending = nil
170195 }
171- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
196+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
172197 changed := false
173198 copyOut := false
174199 exit := false
@@ -222,7 +247,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
222247 ac.copyMode = nil
223248 }
224249 ac.copyMu.Unlock()
225- tb.mu.Unlock()
250+ p.mu.Unlock()
226251
227252 if copyOut && text != "" {
228253 chunks := scopy.OSC52(text)
@@ -309,14 +334,27 @@ func isCopyEscapePrefix(data []byte) bool {
309334
310335 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
311336 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)
313343 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+ }
315348 frame := renderer.NewFrame(width, screenRows+2)
316349 drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles)
350+ base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
317351 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)])
319356 }
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)])
321359 return frame, []renderer.Damage{renderer.FullRedraw()}
322360 }
@@ -17,20 +17,21 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 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 return56 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 = nil60 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 return64 return
58 }65 }
@@ -62,7 +69,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
62 ac.copyMode = nil69 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 return80 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 = false93 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 = false119 ac.copyPressRowValid = false
101 ac.copyDragging = false120 ac.copyDragging = false
102 ac.copyMu.Unlock()121 ac.copyMu.Unlock()
103 }122 }
104- tb.mu.Unlock()123+ p.mu.Unlock()
105 return124 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 return130 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.Row133 absRow := ac.copyMode.ViewportTop + ev.Row
115 changed := false134 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 = nil182 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 return186 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 = combined193 data = combined
169 ac.copyPending = nil194 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 := false197 changed := false
173 copyOut := false198 copyOut := false
174 exit := false199 exit := false
@@ -222,7 +247,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
222 ac.copyMode = nil247 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.Height344+ 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) {
168168 return false
169169 }
170170 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
173179 }, 2*time.Second, 5*time.Millisecond)
174180
175181 sess := firstSession(d)
@@ -168,8 +168,14 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
168 return false168 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) >= 12172+ 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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -100,6 +99,7 @@ type Daemon struct {
10099 persist *persist.Persister
101100 persistEnabled bool
102101 procCwd func(int) (string, error)
102+ procComm func(int) (string, error)
103103
104104 serveCtx context.Context
105105 serveCancel context.CancelFunc
@@ -176,6 +176,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
176176 shell: shell,
177177 persist: persist.New(nil),
178178 procCwd: platform.ProcessCwd,
179+ procComm: platform.ProcessComm,
179180 done: make(chan struct{}),
180181 animWake: make(chan struct{}, 1),
181182 }
@@ -188,6 +189,9 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
188189 if d.procCwd == nil {
189190 d.procCwd = platform.ProcessCwd
190191 }
192+ if d.procComm == nil {
193+ d.procComm = platform.ProcessComm
194+ }
191195 if records, err := d.persist.LoadAll(); err != nil {
192196 d.log.Warn("loading persisted sessions failed", "err", err)
193197 } else {
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
@@ -100,6 +99,7 @@ type Daemon struct {
100 persist *persist.Persister99 persist *persist.Persister
101 persistEnabled bool100 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.Context104 serveCtx context.Context
105 serveCancel context.CancelFunc105 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.ProcessCwd190 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
243243 tabs := make([]*tab, 0, len(ptys))
244244 for _, p := range ptys {
245245 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)
247252 }
248253 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
249254 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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -31,6 +30,7 @@ import (
3130
3231 scopy "github.com/bnema/vev/internal/usecase/copy"
3332 "github.com/bnema/vev/internal/usecase/keys"
33+ "github.com/bnema/vev/internal/usecase/layout"
3434 "github.com/bnema/vev/internal/usecase/mouse"
3535 )
3636
@@ -73,6 +73,23 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7373 }
7474
7575 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+ }
7693 switch ev.Button {
7794 case mouse.Left:
7895 d.copyMouse(sess, ac, ev)
@@ -85,20 +102,69 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
85102 }
86103
87104 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()
91153 scrollbackRows := 0
92- if tb.scrollback != nil {
93- scrollbackRows = tb.scrollback.Len()
154+ if p.scrollback != nil {
155+ scrollbackRows = p.scrollback.Len()
94156 }
95- tb.mu.Unlock()
157+ p.mu.Unlock()
96158
97159 if mouseMode != 0 {
98160 if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
99161 return
100162 }
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+ }
102168 return
103169 }
104170
@@ -130,9 +196,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
130196 return
131197 }
132198
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()
136202
137203 ac.copyMu.Lock()
138204 mode := scopy.NewMode(snap)
@@ -165,6 +231,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
165231 }
166232
167233 func sgrRowOffset(raw []byte, delta int) []byte {
234+ return sgrOffset(raw, 0, delta)
235+}
236+
237+func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
168238 if len(raw) < len("\x1b[<0;1;1M") {
169239 return raw
170240 }
@@ -177,12 +247,17 @@ func sgrRowOffset(raw []byte, delta int) []byte {
177247 if len(parts) != 3 {
178248 return raw
179249 }
250+ cx, err := strconv.Atoi(string(parts[1]))
251+ if err != nil {
252+ return raw
253+ }
180254 cy, err := strconv.Atoi(string(parts[2]))
181255 if err != nil {
182256 return raw
183257 }
184- cy += delta
185- if cy < 1 {
258+ cx += colDelta
259+ cy += rowDelta
260+ if cx < 1 || cy < 1 {
186261 return raw
187262 }
188263
@@ -190,7 +265,7 @@ func sgrRowOffset(raw []byte, delta int) []byte {
190265 out = append(out, raw[:3]...)
191266 out = append(out, parts[0]...)
192267 out = append(out, ';')
193- out = append(out, parts[1]...)
268+ out = strconv.AppendInt(out, int64(cx), 10)
194269 out = append(out, ';')
195270 out = strconv.AppendInt(out, int64(cy), 10)
196271 out = append(out, raw[end])
@@ -211,7 +286,13 @@ func (h daemonKeyHandler) Forward(data []byte) {
211286 if tb == nil {
212287 return
213288 }
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 {
215296 h.d.log.Error("pty write failed", "err", err, "session", sess.name)
216297 }
217298 }
@@ -226,6 +307,14 @@ func (h daemonKeyHandler) Action(action keys.Action) {
226307 h.d.enterPalette(sess, h.ac)
227308 case keys.ActionJumpAttention:
228309 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)
229318 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
230319 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
231320 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 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.Height105+ 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 := 0153 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 return161 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 return168 return
103 }169 }
104 170
@@ -130,9 +196,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
130 return196 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 raw239 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 raw248 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 raw256 return raw
183 }257 }
184- cy += delta258+ cx += colDelta
185- if cy < 1 {259+ cy += rowDelta
260+ if cx < 1 || cy < 1 {
186 return raw261 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 return287 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 (
1111
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1415 scopy "github.com/bnema/vev/internal/usecase/copy"
16+ "github.com/bnema/vev/internal/usecase/layout"
1517 )
1618
1719 // --- test doubles -----------------------------------------------------------
@@ -406,7 +408,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
406408
407409 // Simulate 5 lines evicted into scrollback between the Press and the
408410 // first Motion (e.g. the child kept producing output).
409- for i := 0; i < 5; i++ {
411+ for range 5 {
410412 sess.tabs[0].scrollback.Append(testRow("evicted"))
411413 }
412414
@@ -441,3 +443,48 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
441443 default:
442444 }
443445 }
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 the409 // 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
33 import (
44 "github.com/bnema/vev/internal/domain"
55 "github.com/bnema/vev/internal/usecase/command"
6+ "github.com/bnema/vev/internal/usecase/layout"
67 "github.com/bnema/vev/internal/usecase/palette"
78 "github.com/bnema/vev/internal/usecase/ui"
89 "github.com/bnema/vev/pkg/renderer"
@@ -159,6 +160,50 @@ func (e paletteExec) CloseTab() error {
159160 return nil
160161 }
161162
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+
162207 func (e paletteExec) NextTab() error {
163208 e.sess.switchRelative(1)
164209 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 nil160 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) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
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)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 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"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -307,6 +307,17 @@ func TestPaletteExecMethods(t *testing.T) {
307307 ac.copyMu.Lock()
308308 ac.copyMode = nil
309309 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())
310321 require.NoError(t, exec.CloseTab())
311322 require.Len(t, sess.tabs, 2)
312323 // 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.Code113 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.WaitGroup122 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 = nil308 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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -500,9 +499,19 @@ func snapshotPickerPreview(tb *tab) picker.Preview {
500499 }
501500
502501 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)
504513 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)...)
506515 }
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}
508517 }
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 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) {
270270 tb := sess.tabs[i%len(sess.tabs)]
271271 wg.Go(func() {
272272 tb.mu.Lock()
273- tb.screen.Write([]byte("render-race"))
273+ p := tb.focusedPane()
274274 tb.mu.Unlock()
275+ if p != nil {
276+ p.mu.Lock()
277+ p.screen.Write([]byte("render-race"))
278+ p.mu.Unlock()
279+ }
275280 d.render(sess, tb)
276281 })
277282 }
@@ -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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -30,8 +29,11 @@ import (
3029 "strconv"
3130 "time"
3231
32+ "github.com/bnema/vev/internal/domain"
3333 scopy "github.com/bnema/vev/internal/usecase/copy"
34+ "github.com/bnema/vev/internal/usecase/layout"
3435 themeui "github.com/bnema/vev/internal/usecase/theme"
36+ "github.com/bnema/vev/internal/usecase/ui"
3537 "github.com/bnema/vev/pkg/renderer"
3638 "github.com/bnema/vev/pkg/vt"
3739 )
@@ -43,56 +45,65 @@ func signal(ch chan struct{}) {
4345 }
4446 }
4547
46-func (d *Daemon) ptyReader(sess *session, tb *tab) {
48+func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) {
4749 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+ }
4859 buf := make([]byte, ptyReadBufSize)
4960 var resp []byte
5061 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()
5667 for {
57- n, err := tb.pty.Read(buf)
68+ n, err := p.pty.Read(buf)
5869 if n > 0 {
5970 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()
6576 select {
6677 case <-attentionCh:
6778 d.noteAttention(sess, tb)
6879 default:
6980 }
7081 if len(resp) > 0 {
71- if _, writeErr := tb.pty.Write(resp); writeErr != nil {
82+ if _, writeErr := p.pty.Write(resp); writeErr != nil {
7283 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
7384 }
7485 resp = resp[:0]
7586 }
7687 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()
8192 if isSyncing {
82- go d.syncWatchdog(tb, gen)
93+ go d.syncWatchdog(p, gen)
8394 }
8495 }
8596 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
86- signal(tb.flush)
97+ signal(p.flush)
8798 continue
8899 }
89100 if isSyncing {
90101 continue
91102 }
92- signal(tb.dirty)
103+ signal(p.dirty)
93104 }
94105 if err != nil {
95- d.closeTab(sess, tb, true)
106+ d.reapPane(sess, tb, p)
96107 return
97108 }
98109 }
@@ -101,8 +112,17 @@ func (d *Daemon) ptyReader(sess *session, tb *tab) {
101112 // scheduler debounces dirty signals. The first dirty opens a short tab;
102113 // sustained floods progressively widen that tab, while isolated updates
103114 // 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) {
105116 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+ }
106126 delay := minDebounceInterval
107127 lastRender := d.clock.Now()
108128 outer:
@@ -110,13 +130,13 @@ outer:
110130 select {
111131 case <-sess.ctx.Done():
112132 return
113- case <-tabDone(tb):
133+ case <-paneDone(p):
114134 return
115- case <-tb.flush:
135+ case <-p.flush:
116136 d.render(sess, tb)
117137 lastRender = d.clock.Now()
118138 continue
119- case <-tb.dirty:
139+ case <-p.dirty:
120140 if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
121141 delay = minDebounceInterval
122142 }
@@ -130,10 +150,10 @@ outer:
130150 case <-sess.ctx.Done():
131151 timer.Stop()
132152 return
133- case <-tabDone(tb):
153+ case <-paneDone(p):
134154 timer.Stop()
135155 return
136- case <-tb.flush:
156+ case <-p.flush:
137157 if !timer.Stop() {
138158 select {
139159 case <-timer.C():
@@ -143,7 +163,7 @@ outer:
143163 d.render(sess, tb)
144164 lastRender = d.clock.Now()
145165 continue outer
146- case <-tb.dirty:
166+ case <-p.dirty:
147167 coalesced++
148168 case <-timer.C():
149169 break absorb
@@ -169,30 +189,23 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
169189 return delay
170190 }
171191
172-func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
192+func (d *Daemon) syncWatchdog(p *pane, gen uint64) {
173193 timer := d.clock.NewTimer(maxSyncUpdateDuration)
174194 select {
175- case <-tabDone(tb):
195+ case <-paneDone(p):
176196 timer.Stop()
177197 return
178198 case <-timer.C():
179199 }
180200
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()
184204 return
185205 }
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)
196209 }
197210
198211 func syncUpdateEndIn(data []byte) bool {
@@ -203,15 +216,18 @@ func syncUpdateEndIn(data []byte) bool {
203216 // damage so it never grows unbounded while headless.
204217 func (d *Daemon) render(sess *session, tb *tab) {
205218 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()
213220 previewer := tb.previewClient
214221 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()
215231
216232 sess.mu.Lock()
217233 ac := sess.client
@@ -226,16 +242,16 @@ func (d *Daemon) render(sess *session, tb *tab) {
226242 d.paint(sess, ac, false)
227243 return
228244 }
229- tb.mu.Lock()
230- tb.screen.ClearDamage()
231- tb.mu.Unlock()
245+ p.mu.Lock()
246+ p.screen.ClearDamage()
247+ p.mu.Unlock()
232248 return
233249 }
234250
235251 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()
239255 return
240256 }
241257 d.paint(sess, ac, false)
@@ -288,8 +304,27 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
288304 bars.theme = ac.getTheme()
289305
290306 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+ }
291320
292321 tb.mu.Lock()
322+ p := tb.focusedPane()
323+ if p == nil {
324+ tb.mu.Unlock()
325+ ac.sendMu.Unlock()
326+ return
327+ }
293328 if reset || copyActive || pickerActive || paletteActive || promptActive {
294329 ac.rend.Reset()
295330 ac.bars.Reset()
@@ -303,7 +338,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
303338 }
304339 if pickerActive {
305340 if previewTab == tb {
306- preview = pickerPreviewFromLockedTab(tb)
341+ p.mu.Lock()
342+ preview = pickerPreviewFromLockedPane(p)
343+ p.mu.Unlock()
307344 }
308345 frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)
309346 }
@@ -315,13 +352,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
315352 frame, damage = composePromptClientFrame(promptModel, frame, styles)
316353 ac.promptMu.Unlock()
317354 }
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()
319358 data, err := ac.rend.Draw(frame, damage)
320359 var cursorTail []byte
321360 if err == nil {
322361 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
323362 }
324- tb.screen.ClearDamage()
363+ for _, pane := range tb.panesSnapshot() {
364+ pane.mu.Lock()
365+ pane.screen.ClearDamage()
366+ pane.mu.Unlock()
367+ }
325368 tb.mu.Unlock()
326369
327370 var serr error
@@ -425,13 +468,18 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
425468 if len(caches) > 0 {
426469 cache = caches[0]
427470 }
471+ p := tb.focusedPane()
428472 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+ }
430477 frame := renderer.NewFrame(width, screenRows+2)
431478 topBar := frame.Row(0)
432479 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles)
480+ contentFrame, contentDamage := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
433481 for y := range screenRows {
434- copy(frame.Row(y+1), tb.screen.Frame.Row(y))
482+ copy(frame.Row(y+1), contentFrame.Row(y))
435483 }
436484 bottomBar := frame.Row(screenRows + 1)
437485 drawStatusBarState(bottomBar, bars, styles)
@@ -441,7 +489,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
441489 }
442490 return frame, []renderer.Damage{renderer.FullRedraw()}
443491 }
444- damage := offsetDamage(tb.screen.Damage())
492+ damage := translateDamage(contentDamage, 0, 1)
445493 if cache == nil || !sameCells(cache.top, topBar) {
446494 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
447495 }
@@ -454,6 +502,130 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
454502 return frame, damage
455503 }
456504
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+
457629 func (c *barCache) capture(top, bottom []renderer.Cell) {
458630 c.top = cloneCells(c.top, top)
459631 c.bottom = cloneCells(c.bottom, bottom)
@@ -482,11 +654,16 @@ func sameCells(a, b []renderer.Cell) bool {
482654 }
483655
484656 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 {
485661 out := make([]renderer.Damage, len(in))
486662 for i, d := range in {
487663 out[i] = d
488664 if d.Kind != renderer.DamageFullRedraw {
489- out[i].Y++
665+ out[i].X += dx
666+ out[i].Y += dy
490667 }
491668 }
492669 return out
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 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 []byte60 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.syncGen90+ 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 continue98 continue
88 }99 }
89 if isSyncing {100 if isSyncing {
90 continue101 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 return107 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 updates113 // 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 := minDebounceInterval126 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 return132 return
113- case <-tabDone(tb):133+ case <-paneDone(p):
114 return134 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 continue138 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 = minDebounceInterval141 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 return152 return
133- case <-tabDone(tb):153+ case <-paneDone(p):
134 timer.Stop()154 timer.Stop()
135 return155 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 outer165 continue outer
146- case <-tb.dirty:166+ case <-p.dirty:
147 coalesced++167 coalesced++
148 case <-timer.C():168 case <-timer.C():
149 break absorb169 break absorb
@@ -169,30 +189,23 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
169 return delay189 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 return197 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 return204 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.previewClient220 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.client233 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 return243 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 return248 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 return255 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 []byte359 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 error370 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.Height473+ 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, damage502 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] = d663 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 out669 return out
M internal/usecase/daemon/render_test.go +80 -13
@@ -17,6 +17,8 @@ import (
1717 "github.com/bnema/vev/internal/domain"
1818 "github.com/bnema/vev/internal/ports"
1919 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"
2022 "github.com/bnema/vev/pkg/renderer"
2123 "github.com/bnema/vev/pkg/vt"
2224 )
@@ -323,6 +325,68 @@ func TestOffsetDamageShiftsScreenDamageBelowTopBar(t *testing.T) {
323325 }, damage)
324326 }
325327
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+
326390 func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
327391 sess, win := newBarCacheTestSession()
328392 var cache barCache
@@ -463,22 +527,25 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
463527
464528 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
465529 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
467534 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
468535 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
469536 ac.setSession(sess)
470537
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()
474541
475542 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{}{}
478545
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()
482549 timerCh <- time.Now()
483550
484551 select {
@@ -488,10 +555,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
488555 }
489556 require.Equal(t, int32(0), outputs.Load())
490557
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{}{}
495562 select {
496563 case <-gotOutput:
497564 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 barCache392 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 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2525 package daemon
2626
2727 import (
@@ -37,6 +37,7 @@ import (
3737 "github.com/bnema/vev/internal/persist"
3838 "github.com/bnema/vev/internal/ports"
3939 scopy "github.com/bnema/vev/internal/usecase/copy"
40+ "github.com/bnema/vev/internal/usecase/layout"
4041 "github.com/bnema/vev/pkg/vt"
4142 )
4243
@@ -56,26 +57,33 @@ type session struct {
5657 createdAt int64
5758 }
5859
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.
6262 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.
6365 pty ports.PTY
64- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
6566 screen *vt.Screen
6667 scrollback *scopy.Scrollback
6768 dirty chan struct{}
6869 flush chan struct{}
6970 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+
7081 // previewClient tracks the one client currently previewing this tab in the picker.
7182 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
7283 previewClient *attachedClient
7384 // attention and attentionAt are guarded by the owning session.mu.
7485 attention bool
7586 attentionAt time.Time
76- size domain.Size
77- ctx context.Context
78- cancel context.CancelFunc
7987 }
8088
8189 // 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 {
183191 tb := newTab(pty, tbSize)
184192 if client != nil {
185193 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+ }
187202 }
188203 d.mu.Lock()
189204 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 {
202217 }
203218
204219 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)
208222 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,
214231 size: sz,
215232 }
216233 }
217234
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+
218270 func tabSize(clientSize domain.Size) domain.Size {
219271 if !clientSize.Valid() {
220272 clientSize = defaultSize
@@ -224,9 +276,18 @@ func tabSize(clientSize domain.Size) domain.Size {
224276 }
225277
226278 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) {
227288 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)
230291 }
231292
232293 // 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) {
345406 if tb.cancel != nil {
346407 tb.cancel()
347408 }
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+ }
349418 if repaint && ac != nil {
350419 d.paint(sess, ac, true)
351420 }
@@ -425,7 +494,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
425494 sess.mu.Unlock()
426495 for _, tb := range tabs {
427496 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+ }
429506 }
430507 if empty {
431508 d.doneOnce.Do(func() { close(d.done) })
@@ -509,7 +586,13 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
509586 if tb == nil {
510587 return
511588 }
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())
513596 if err != nil || cwd == "" {
514597 return
515598 }
@@ -17,11 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 takes23+// 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 daemon25 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 int6457 createdAt int64
57 }58 }
58 59
59-// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output60+// 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 is61+// 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.PTY65 pty ports.PTY
64- mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
65 screen *vt.Screen66 screen *vt.Screen
66 scrollback *scopy.Scrollback67 scrollback *scopy.Scrollback
67 dirty chan struct{}68 dirty chan struct{}
68 flush chan struct{}69 flush chan struct{}
69 syncGen uint6470 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 *attachedClient83 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 bool85 attention bool
75 attentionAt time.Time86 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 is89 // 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 = defaultSize272 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 one293 // 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 return587 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 return597 return
515 }598 }
M internal/usecase/daemon/status.go +5 -6
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
M internal/usecase/daemon/wire.go +5 -6
@@ -17,12 +17,11 @@
1717 // when the parent context is cancelled (graceful shutdown notifies any
1818 // attached clients with ReasonServerShutdown).
1919 //
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.
2625 package daemon
2726
2827 import (
@@ -17,12 +17,11 @@
17 // when the parent context is cancelled (graceful shutdown notifies any17 // 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 guarded20+// 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 by21+// 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 always22+// 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 on24+// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25-// a slow client.
26 package daemon25 package daemon
27 26
28 import (27 import (
M internal/usecase/keys/router.go +75 -11
@@ -3,6 +3,7 @@
33 package keys
44
55 import (
6+ "slices"
67 "sync"
78 "time"
89 "unicode/utf8"
@@ -30,6 +31,10 @@ const (
3031 ActionSwitchTab8
3132 ActionSwitchTab9
3233 ActionJumpAttention
34+ ActionFocusPaneLeft
35+ ActionFocusPaneRight
36+ ActionFocusPaneUp
37+ ActionFocusPaneDown
3338 )
3439
3540 // Handler receives router outputs. Forward is called only for bytes that should
@@ -98,13 +103,23 @@ func (r *Router) route(data []byte) {
98103 r.retainESC()
99104 return
100105 }
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+ }
101117 next := data[i+1]
102118 if passThroughPrefix(next) {
103119 buf = append(buf, ESC, next)
104120 i += 2
105121 continue
106122 }
107- remaining := data[i+1:]
108123 if action, size, ok := binding(remaining); ok {
109124 flush()
110125 r.h.Action(action)
@@ -123,11 +138,14 @@ func (r *Router) route(data []byte) {
123138 }
124139
125140 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)
130147 }
148+ next := data[0]
131149 if action, size, ok := binding(data); ok {
132150 r.h.Action(action)
133151 return size
@@ -136,15 +154,19 @@ func (r *Router) routeAfterPendingESC(data []byte, pendingAltLen int) int {
136154 r.retainESC(data)
137155 return len(data)
138156 }
139- key, size := utf8.DecodeRune(data)
157+ _, size := utf8.DecodeRune(data)
140158 if size > 1 {
141159 r.forward(append([]byte{ESC}, data[:size]...))
142160 return size
143161 }
144- if key == utf8.RuneError && pendingAltLen > 0 {
162+ if pendingAltLen > 0 {
145163 r.forward(append([]byte{ESC}, data[:pendingAltLen]...))
146164 return pendingAltLen
147165 }
166+ if passThroughPrefix(next) {
167+ r.forward([]byte{ESC, next})
168+ return 1
169+ }
148170 r.forward([]byte{ESC})
149171 return 0
150172 }
@@ -200,6 +222,50 @@ func (r *Router) forward(data []byte) {
200222
201223 func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
202224
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+
203269 func binding(data []byte) (Action, int, bool) {
204270 if len(data) == 0 {
205271 return 0, 0, false
@@ -238,10 +304,8 @@ var topRowDigitAliases = [][]rune{
238304 // Ctrl+digit bindings can share the same layout support.
239305 func topRowDigitIndex(key rune) (int, bool) {
240306 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
245309 }
246310 }
247311 return 0, false
@@ -3,6 +3,7 @@
3 package keys3 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 ActionSwitchTab831 ActionSwitchTab8
31 ActionSwitchTab932 ActionSwitchTab9
32 ActionJumpAttention33 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 should40 // 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 return104 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 += 2120 i += 2
105 continue121 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 1144+ } 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 size151 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 size160 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 pendingAltLen164 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 0171 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, false271 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, false311 return 0, false
M internal/usecase/keys/router_test.go +75 -0
@@ -244,6 +244,81 @@ func TestRouterPassesThroughTerminalEscapePrefixes(t *testing.T) {
244244 }
245245 }
246246
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+
247322 func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
248323 clk := &fakeClock{}
249324 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) {
184184 }
185185
186186 func findTerminator(data []byte) (start int, end int) {
187- for i := 0; i < len(data); i++ {
187+ for i := range data {
188188 switch data[i] {
189189 case '\x07':
190190 return i, i + 1
@@ -250,6 +250,23 @@ func BorderStyle(t Theme) renderer.Style {
250250 return style
251251 }
252252
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+
253270 func SelectionStyle(t Theme) renderer.Style {
254271 if !usable(t) {
255272 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 + 1190 return i, i + 1
@@ -250,6 +250,23 @@ func BorderStyle(t Theme) renderer.Style {
250 return style250 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close