detached bcfef74

feat(workspaces): declarative slots spawned in the background

workspace.<name>.column.N = <width>, <command> reserves a column for the
window of a command. nefertty spawns it at startup with NEFERTTY_SLOT set to
a token; the wayland adapter reads the token from the client process
environment (PID from socket credentials) and core places the first window
of that token in its slot without taking focus. Empty slots are refilled
when their workspace is shown, never on their own. Live reload changes only
edited slots. Widths accept percentages.
commit
bcfef742c76d2f46e53d476fc849070127686eaf
author
brice · · 2026-09-24 18:01 UTC
parent
f2678e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +830 -54
M .mockery.yml +7 -0
@@ -23,3 +23,10 @@ packages:
2323 dir: internal/adapters/drm
2424 pkgname: drm
2525 filename: cursor_plane_mock_test.go
26+ github.com/bnema/nefertty/internal/adapters/wayland:
27+ interfaces:
28+ procEnv:
29+ config:
30+ dir: internal/adapters/wayland
31+ pkgname: wayland
32+ filename: proc_env_mock_test.go
@@ -23,3 +23,10 @@ packages:
23 dir: internal/adapters/drm23 dir: internal/adapters/drm
24 pkgname: drm24 pkgname: drm
25 filename: cursor_plane_mock_test.go25 filename: cursor_plane_mock_test.go
26+ github.com/bnema/nefertty/internal/adapters/wayland:
27+ interfaces:
28+ procEnv:
29+ config:
30+ dir: internal/adapters/wayland
31+ pkgname: wayland
32+ filename: proc_env_mock_test.go
M examples/config +7 -0
@@ -39,6 +39,13 @@ layout.presets = 1/3, 1/2, 2/3, 1
3939 # workspace.dev.max-columns = 3
4040 # workspace.dev.overflow = fixed
4141 # bind.cmd+d = workspace dev
42+#
43+# Slots: column.N = <width>, <command>. nefertty starts each command in the
44+# background and puts its first window in column N, without taking focus.
45+# A closed slot stays empty until the workspace is shown again. Apps that
46+# reuse a running instance need a new-instance flag.
47+# workspace.dev.column.1 = 67%, code --new-window
48+# workspace.dev.column.2 = 33%, foot
4249
4350 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
4451 # The first listed display that is connected and not off is used.
@@ -39,6 +39,13 @@ layout.presets = 1/3, 1/2, 2/3, 1
39 # workspace.dev.max-columns = 339 # workspace.dev.max-columns = 3
40 # workspace.dev.overflow = fixed40 # workspace.dev.overflow = fixed
41 # bind.cmd+d = workspace dev41 # bind.cmd+d = workspace dev
42+#
43+# Slots: column.N = <width>, <command>. nefertty starts each command in the
44+# background and puts its first window in column N, without taking focus.
45+# A closed slot stays empty until the workspace is shown again. Apps that
46+# reuse a running instance need a new-instance flag.
47+# workspace.dev.column.1 = 67%, code --new-window
48+# workspace.dev.column.2 = 33%, foot
42 49
43 # Displays (drm backend): WxH, WxH@Hz, preferred or off.50 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
44 # The first listed display that is connected and not off is used.51 # The first listed display that is connected and not off is used.
M internal/adapters/config/config.go +27 -2
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "regexp"
12+ "slices"
1213 "sort"
1314 "strconv"
1415 "strings"
@@ -404,7 +405,7 @@ func set(c *ports.Config, key, v string) error {
404405 }
405406 for _, s := range list {
406407 if !width(s) {
407- return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)
408+ return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
408409 }
409410 }
410411 c.Layout.Presets = list
@@ -482,7 +483,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
482483 }
483484 w.Overflow = v
484485 default:
485- return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
486+ n, ok := strings.CutPrefix(field, "column.")
487+ if !ok {
488+ return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
489+ }
490+ index, err := strconv.Atoi(n)
491+ if err != nil || index < 1 || index > 16 {
492+ return fmt.Errorf("column number must be between 1 and 16")
493+ }
494+ size, cmd, _ := strings.Cut(v, ",")
495+ size = strings.TrimSpace(size)
496+ argv := strings.Fields(cmd)
497+ if !width(size) || len(argv) == 0 {
498+ return fmt.Errorf("must be <width>, <command> like 67%%, foot")
499+ }
500+ slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
501+ i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
502+ if i < len(w.Slots) && w.Slots[i].Index == index {
503+ w.Slots[i] = slot
504+ } else {
505+ w.Slots = slices.Insert(w.Slots, i, slot)
506+ }
486507 }
487508 return nil
488509 }
@@ -680,6 +701,10 @@ func width(s string) bool {
680701 if s == "1" {
681702 return true
682703 }
704+ if n, ok := strings.CutSuffix(s, "%"); ok {
705+ v, e := strconv.Atoi(n)
706+ return e == nil && v > 0 && v <= 100
707+ }
683708 if strings.HasSuffix(s, "px") {
684709 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
685710 return e == nil && n > 0
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "regexp"11 "regexp"
12+ "slices"
12 "sort"13 "sort"
13 "strconv"14 "strconv"
14 "strings"15 "strings"
@@ -404,7 +405,7 @@ func set(c *ports.Config, key, v string) error {
404 }405 }
405 for _, s := range list {406 for _, s := range list {
406 if !width(s) {407 if !width(s) {
407- return fmt.Errorf("%q: must be a fraction like 1/2, 1, or pixels like 800px", s)408+ return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
408 }409 }
409 }410 }
410 c.Layout.Presets = list411 c.Layout.Presets = list
@@ -482,7 +483,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
482 }483 }
483 w.Overflow = v484 w.Overflow = v
484 default:485 default:
485- return fmt.Errorf("unknown key (hidden, max-columns, overflow)")486+ n, ok := strings.CutPrefix(field, "column.")
487+ if !ok {
488+ return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
489+ }
490+ index, err := strconv.Atoi(n)
491+ if err != nil || index < 1 || index > 16 {
492+ return fmt.Errorf("column number must be between 1 and 16")
493+ }
494+ size, cmd, _ := strings.Cut(v, ",")
495+ size = strings.TrimSpace(size)
496+ argv := strings.Fields(cmd)
497+ if !width(size) || len(argv) == 0 {
498+ return fmt.Errorf("must be <width>, <command> like 67%%, foot")
499+ }
500+ slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
501+ i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
502+ if i < len(w.Slots) && w.Slots[i].Index == index {
503+ w.Slots[i] = slot
504+ } else {
505+ w.Slots = slices.Insert(w.Slots, i, slot)
506+ }
486 }507 }
487 return nil508 return nil
488 }509 }
@@ -680,6 +701,10 @@ func width(s string) bool {
680 if s == "1" {701 if s == "1" {
681 return true702 return true
682 }703 }
704+ if n, ok := strings.CutSuffix(s, "%"); ok {
705+ v, e := strconv.Atoi(n)
706+ return e == nil && v > 0 && v <= 100
707+ }
683 if strings.HasSuffix(s, "px") {708 if strings.HasSuffix(s, "px") {
684 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))709 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
685 return e == nil && n > 0710 return e == nil && n > 0
M internal/adapters/config/config_test.go +26 -0
@@ -267,3 +267,29 @@ workspace.lost.hidden = on
267267 t.Fatal(w)
268268 }
269269 }
270+
271+func TestWorkspaceSlots(t *testing.T) {
272+ c, w := parseString(t, `workspace.dev.hidden = on
273+workspace.dev.column.2 = 33%, foot --title x
274+workspace.dev.column.1 = 67%, code --new-window
275+workspace.dev.column.2 = 1/3, kitty
276+workspace.dev.column.0 = 50%, foot
277+workspace.dev.column.3 = 50%
278+workspace.dev.column.4 = huge, foot
279+bind.cmd+d = workspace dev
280+`)
281+ want := []ports.SlotConfig{
282+ {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
283+ {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
284+ }
285+ if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
286+ t.Fatalf("%+v", c.Workspaces)
287+ }
288+ lines := []int{}
289+ for _, x := range w {
290+ lines = append(lines, x.Line)
291+ }
292+ if !reflect.DeepEqual(lines, []int{4, 5, 6, 7}) {
293+ t.Fatal(w)
294+ }
295+}
@@ -267,3 +267,29 @@ workspace.lost.hidden = on
267 t.Fatal(w)267 t.Fatal(w)
268 }268 }
269 }269 }
270+
271+func TestWorkspaceSlots(t *testing.T) {
272+ c, w := parseString(t, `workspace.dev.hidden = on
273+workspace.dev.column.2 = 33%, foot --title x
274+workspace.dev.column.1 = 67%, code --new-window
275+workspace.dev.column.2 = 1/3, kitty
276+workspace.dev.column.0 = 50%, foot
277+workspace.dev.column.3 = 50%
278+workspace.dev.column.4 = huge, foot
279+bind.cmd+d = workspace dev
280+`)
281+ want := []ports.SlotConfig{
282+ {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
283+ {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
284+ }
285+ if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
286+ t.Fatalf("%+v", c.Workspaces)
287+ }
288+ lines := []int{}
289+ for _, x := range w {
290+ lines = append(lines, x.Line)
291+ }
292+ if !reflect.DeepEqual(lines, []int{4, 5, 6, 7}) {
293+ t.Fatal(w)
294+ }
295+}
M internal/adapters/launcher/launcher.go +1 -1
@@ -70,7 +70,7 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
7070 continue
7171 }
7272 cmd := exec.Command(path, req.Argv[1:]...)
73- cmd.Env = l.env
73+ cmd.Env = append(append([]string(nil), l.env...), req.Env...)
7474 cmd.Stdin = nil
7575 cmd.Stdout = nil
7676 cmd.Stderr = nil
@@ -70,7 +70,7 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro
70 continue70 continue
71 }71 }
72 cmd := exec.Command(path, req.Argv[1:]...)72 cmd := exec.Command(path, req.Argv[1:]...)
73- cmd.Env = l.env73+ cmd.Env = append(append([]string(nil), l.env...), req.Env...)
74 cmd.Stdin = nil74 cmd.Stdin = nil
75 cmd.Stdout = nil75 cmd.Stdout = nil
76 cmd.Stderr = nil76 cmd.Stderr = nil
M internal/adapters/launcher/launcher_test.go +3 -2
@@ -31,12 +31,13 @@ func TestRun(t *testing.T) {
3131 }()
3232 reqs <- ports.SpawnRequest{}
3333 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-nefertty-binary"}}
34- reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok > $FILE"}}
34+ // Env adds to the child environment (slot tokens).
35+ reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok$NEFERTTY_SLOT > $FILE"}, Env: []string{"NEFERTTY_SLOT=7"}}
3536 deadline := time.After(3 * time.Second)
3637 for {
3738 data, err := os.ReadFile(file)
3839 if err == nil {
39- if string(data) != "ok\n" {
40+ if string(data) != "ok7\n" {
4041 t.Fatalf("output: %q", data)
4142 }
4243 break
@@ -31,12 +31,13 @@ func TestRun(t *testing.T) {
31 }()31 }()
32 reqs <- ports.SpawnRequest{}32 reqs <- ports.SpawnRequest{}
33 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-nefertty-binary"}}33 reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-nefertty-binary"}}
34- reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok > $FILE"}}34+ // Env adds to the child environment (slot tokens).
35+ reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok$NEFERTTY_SLOT > $FILE"}, Env: []string{"NEFERTTY_SLOT=7"}}
35 deadline := time.After(3 * time.Second)36 deadline := time.After(3 * time.Second)
36 for {37 for {
37 data, err := os.ReadFile(file)38 data, err := os.ReadFile(file)
38 if err == nil {39 if err == nil {
39- if string(data) != "ok\n" {40+ if string(data) != "ok7\n" {
40 t.Fatalf("output: %q", data)41 t.Fatalf("output: %q", data)
41 }42 }
42 break43 break
M internal/adapters/wayland/keyboard_test.go +5 -1
@@ -26,7 +26,8 @@ func keymapText(t *testing.T) string {
2626 return string(data)
2727 }
2828
29-func keyboardServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
29+// keyboardServer runs a server; setup, if any, runs before Run starts.
30+func keyboardServer(t *testing.T, setup ...func(*Server)) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
3031 t.Helper()
3132 dir := t.TempDir()
3233 events := make(chan ports.ClientEvent, 16)
@@ -35,6 +36,9 @@ func keyboardServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.C
3536 if err != nil {
3637 t.Fatal(err)
3738 }
39+ for _, f := range setup {
40+ f(s)
41+ }
3842 ctx, cancel := context.WithCancel(context.Background())
3943 done := make(chan error, 1)
4044 go func() { done <- s.Run(ctx) }()
@@ -26,7 +26,8 @@ func keymapText(t *testing.T) string {
26 return string(data)26 return string(data)
27 }27 }
28 28
29-func keyboardServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {29+// keyboardServer runs a server; setup, if any, runs before Run starts.
30+func keyboardServer(t *testing.T, setup ...func(*Server)) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
30 t.Helper()31 t.Helper()
31 dir := t.TempDir()32 dir := t.TempDir()
32 events := make(chan ports.ClientEvent, 16)33 events := make(chan ports.ClientEvent, 16)
@@ -35,6 +36,9 @@ func keyboardServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.C
35 if err != nil {36 if err != nil {
36 t.Fatal(err)37 t.Fatal(err)
37 }38 }
39+ for _, f := range setup {
40+ f(s)
41+ }
38 ctx, cancel := context.WithCancel(context.Background())42 ctx, cancel := context.WithCancel(context.Background())
39 done := make(chan error, 1)43 done := make(chan error, 1)
40 go func() { done <- s.Run(ctx) }()44 go func() { done <- s.Run(ctx) }()
A internal/adapters/wayland/proc_env_mock_test.go +102 -0
@@ -0,0 +1,102 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockprocEnv creates a new instance of mockprocEnv. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockprocEnv(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockprocEnv {
17+ mock := &mockprocEnv{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// mockprocEnv is an autogenerated mock type for the procEnv type
26+type mockprocEnv struct {
27+ mock.Mock
28+}
29+
30+type mockprocEnv_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *mockprocEnv) EXPECT() *mockprocEnv_Expecter {
35+ return &mockprocEnv_Expecter{mock: &_m.Mock}
36+}
37+
38+// Lookup provides a mock function for the type mockprocEnv
39+func (_mock *mockprocEnv) Lookup(pid int, key string) (string, bool) {
40+ ret := _mock.Called(pid, key)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Lookup")
44+ }
45+
46+ var r0 string
47+ var r1 bool
48+ if returnFunc, ok := ret.Get(0).(func(int, string) (string, bool)); ok {
49+ return returnFunc(pid, key)
50+ }
51+ if returnFunc, ok := ret.Get(0).(func(int, string) string); ok {
52+ r0 = returnFunc(pid, key)
53+ } else {
54+ r0 = ret.Get(0).(string)
55+ }
56+ if returnFunc, ok := ret.Get(1).(func(int, string) bool); ok {
57+ r1 = returnFunc(pid, key)
58+ } else {
59+ r1 = ret.Get(1).(bool)
60+ }
61+ return r0, r1
62+}
63+
64+// mockprocEnv_Lookup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lookup'
65+type mockprocEnv_Lookup_Call struct {
66+ *mock.Call
67+}
68+
69+// Lookup is a helper method to define mock.On call
70+// - pid int
71+// - key string
72+func (_e *mockprocEnv_Expecter) Lookup(pid any, key any) *mockprocEnv_Lookup_Call {
73+ return &mockprocEnv_Lookup_Call{Call: _e.mock.On("Lookup", pid, key)}
74+}
75+
76+func (_c *mockprocEnv_Lookup_Call) Run(run func(pid int, key string)) *mockprocEnv_Lookup_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ var arg0 int
79+ if args[0] != nil {
80+ arg0 = args[0].(int)
81+ }
82+ var arg1 string
83+ if args[1] != nil {
84+ arg1 = args[1].(string)
85+ }
86+ run(
87+ arg0,
88+ arg1,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *mockprocEnv_Lookup_Call) Return(s string, b bool) *mockprocEnv_Lookup_Call {
95+ _c.Call.Return(s, b)
96+ return _c
97+}
98+
99+func (_c *mockprocEnv_Lookup_Call) RunAndReturn(run func(pid int, key string) (string, bool)) *mockprocEnv_Lookup_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
@@ -0,0 +1,102 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockprocEnv creates a new instance of mockprocEnv. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockprocEnv(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockprocEnv {
17+ mock := &mockprocEnv{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// mockprocEnv is an autogenerated mock type for the procEnv type
26+type mockprocEnv struct {
27+ mock.Mock
28+}
29+
30+type mockprocEnv_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *mockprocEnv) EXPECT() *mockprocEnv_Expecter {
35+ return &mockprocEnv_Expecter{mock: &_m.Mock}
36+}
37+
38+// Lookup provides a mock function for the type mockprocEnv
39+func (_mock *mockprocEnv) Lookup(pid int, key string) (string, bool) {
40+ ret := _mock.Called(pid, key)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Lookup")
44+ }
45+
46+ var r0 string
47+ var r1 bool
48+ if returnFunc, ok := ret.Get(0).(func(int, string) (string, bool)); ok {
49+ return returnFunc(pid, key)
50+ }
51+ if returnFunc, ok := ret.Get(0).(func(int, string) string); ok {
52+ r0 = returnFunc(pid, key)
53+ } else {
54+ r0 = ret.Get(0).(string)
55+ }
56+ if returnFunc, ok := ret.Get(1).(func(int, string) bool); ok {
57+ r1 = returnFunc(pid, key)
58+ } else {
59+ r1 = ret.Get(1).(bool)
60+ }
61+ return r0, r1
62+}
63+
64+// mockprocEnv_Lookup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lookup'
65+type mockprocEnv_Lookup_Call struct {
66+ *mock.Call
67+}
68+
69+// Lookup is a helper method to define mock.On call
70+// - pid int
71+// - key string
72+func (_e *mockprocEnv_Expecter) Lookup(pid any, key any) *mockprocEnv_Lookup_Call {
73+ return &mockprocEnv_Lookup_Call{Call: _e.mock.On("Lookup", pid, key)}
74+}
75+
76+func (_c *mockprocEnv_Lookup_Call) Run(run func(pid int, key string)) *mockprocEnv_Lookup_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ var arg0 int
79+ if args[0] != nil {
80+ arg0 = args[0].(int)
81+ }
82+ var arg1 string
83+ if args[1] != nil {
84+ arg1 = args[1].(string)
85+ }
86+ run(
87+ arg0,
88+ arg1,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *mockprocEnv_Lookup_Call) Return(s string, b bool) *mockprocEnv_Lookup_Call {
95+ _c.Call.Return(s, b)
96+ return _c
97+}
98+
99+func (_c *mockprocEnv_Lookup_Call) RunAndReturn(run func(pid int, key string) (string, bool)) *mockprocEnv_Lookup_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
M internal/adapters/wayland/protocol_test.go +42 -35
@@ -474,6 +474,46 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
474474 kp.SetID(keyboard)
475475 c.Context().Register(kp)
476476 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
477+ mapWindow := toplevelMapper(t, c, events)
478+ a := mapWindow().ID
479+ b := mapWindow().ID
480+ // Dispatch without sending a new request after focus changes.
481+ dispatched := make(chan error, 1)
482+ go func() {
483+ for {
484+ if err := c.Dispatch(); err != nil {
485+ dispatched <- err
486+ return
487+ }
488+ }
489+ }()
490+ enter := func(want []byte) {
491+ t.Helper()
492+ select {
493+ case got := <-kp.enters:
494+ if !bytes.Equal(got, want) {
495+ t.Fatalf("keyboard enter keys = %v, want %v", got, want)
496+ }
497+ case err := <-dispatched:
498+ t.Fatal(err)
499+ case <-time.After(2 * time.Second):
500+ t.Fatal("keyboard enter timeout")
501+ }
502+ }
503+ commands <- ports.FocusWindow{ID: a}
504+ enter(nil)
505+ commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
506+ commands <- ports.FocusWindow{ID: b}
507+ enter([]byte{30, 0, 0, 0})
508+ commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
509+ commands <- ports.FocusWindow{ID: a}
510+ enter(nil)
511+}
512+
513+// toplevelMapper returns a function that maps a new 1x1 xdg toplevel on c and
514+// returns the WindowMapped event it produced.
515+func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() ports.WindowMapped {
516+ t.Helper()
477517 comp := bindProtocol(t, c, "wl_compositor")
478518 wm := bindProtocol(t, c, "xdg_wm_base")
479519 shm := bindProtocol(t, c, "wl_shm")
@@ -493,7 +533,7 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
493533 t.Fatal(err)
494534 }
495535 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
496- mapWindow := func() ports.WindowID {
536+ return func() ports.WindowMapped {
497537 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
498538 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
499539 registerProtocol(t, c, surf)
@@ -519,39 +559,6 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
519559 if err := c.Roundtrip(); err != nil {
520560 t.Fatal(err)
521561 }
522- return mapped(t, events, 2*time.Second).ID
523- }
524- a := mapWindow()
525- b := mapWindow()
526- // Dispatch without sending a new request after focus changes.
527- dispatched := make(chan error, 1)
528- go func() {
529- for {
530- if err := c.Dispatch(); err != nil {
531- dispatched <- err
532- return
533- }
534- }
535- }()
536- enter := func(want []byte) {
537- t.Helper()
538- select {
539- case got := <-kp.enters:
540- if !bytes.Equal(got, want) {
541- t.Fatalf("keyboard enter keys = %v, want %v", got, want)
542- }
543- case err := <-dispatched:
544- t.Fatal(err)
545- case <-time.After(2 * time.Second):
546- t.Fatal("keyboard enter timeout")
547- }
562+ return mapped(t, events, 2*time.Second)
548563 }
549- commands <- ports.FocusWindow{ID: a}
550- enter(nil)
551- commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
552- commands <- ports.FocusWindow{ID: b}
553- enter([]byte{30, 0, 0, 0})
554- commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
555- commands <- ports.FocusWindow{ID: a}
556- enter(nil)
557564 }
@@ -474,6 +474,46 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
474 kp.SetID(keyboard)474 kp.SetID(keyboard)
475 c.Context().Register(kp)475 c.Context().Register(kp)
476 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)476 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keyboard)
477+ mapWindow := toplevelMapper(t, c, events)
478+ a := mapWindow().ID
479+ b := mapWindow().ID
480+ // Dispatch without sending a new request after focus changes.
481+ dispatched := make(chan error, 1)
482+ go func() {
483+ for {
484+ if err := c.Dispatch(); err != nil {
485+ dispatched <- err
486+ return
487+ }
488+ }
489+ }()
490+ enter := func(want []byte) {
491+ t.Helper()
492+ select {
493+ case got := <-kp.enters:
494+ if !bytes.Equal(got, want) {
495+ t.Fatalf("keyboard enter keys = %v, want %v", got, want)
496+ }
497+ case err := <-dispatched:
498+ t.Fatal(err)
499+ case <-time.After(2 * time.Second):
500+ t.Fatal("keyboard enter timeout")
501+ }
502+ }
503+ commands <- ports.FocusWindow{ID: a}
504+ enter(nil)
505+ commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
506+ commands <- ports.FocusWindow{ID: b}
507+ enter([]byte{30, 0, 0, 0})
508+ commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
509+ commands <- ports.FocusWindow{ID: a}
510+ enter(nil)
511+}
512+
513+// toplevelMapper returns a function that maps a new 1x1 xdg toplevel on c and
514+// returns the WindowMapped event it produced.
515+func toplevelMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) func() ports.WindowMapped {
516+ t.Helper()
477 comp := bindProtocol(t, c, "wl_compositor")517 comp := bindProtocol(t, c, "wl_compositor")
478 wm := bindProtocol(t, c, "xdg_wm_base")518 wm := bindProtocol(t, c, "xdg_wm_base")
479 shm := bindProtocol(t, c, "wl_shm")519 shm := bindProtocol(t, c, "wl_shm")
@@ -493,7 +533,7 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
493 t.Fatal(err)533 t.Fatal(err)
494 }534 }
495 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))535 requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
496- mapWindow := func() ports.WindowID {536+ return func() ports.WindowMapped {
497 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()537 surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID()
498 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)538 requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
499 registerProtocol(t, c, surf)539 registerProtocol(t, c, surf)
@@ -519,39 +559,6 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) {
519 if err := c.Roundtrip(); err != nil {559 if err := c.Roundtrip(); err != nil {
520 t.Fatal(err)560 t.Fatal(err)
521 }561 }
522- return mapped(t, events, 2*time.Second).ID562+ return mapped(t, events, 2*time.Second)
523- }
524- a := mapWindow()
525- b := mapWindow()
526- // Dispatch without sending a new request after focus changes.
527- dispatched := make(chan error, 1)
528- go func() {
529- for {
530- if err := c.Dispatch(); err != nil {
531- dispatched <- err
532- return
533- }
534- }
535- }()
536- enter := func(want []byte) {
537- t.Helper()
538- select {
539- case got := <-kp.enters:
540- if !bytes.Equal(got, want) {
541- t.Fatalf("keyboard enter keys = %v, want %v", got, want)
542- }
543- case err := <-dispatched:
544- t.Fatal(err)
545- case <-time.After(2 * time.Second):
546- t.Fatal("keyboard enter timeout")
547- }
548 }563 }
549- commands <- ports.FocusWindow{ID: a}
550- enter(nil)
551- commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}}
552- commands <- ports.FocusWindow{ID: b}
553- enter([]byte{30, 0, 0, 0})
554- commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}}
555- commands <- ports.FocusWindow{ID: a}
556- enter(nil)
557 }564 }
M internal/adapters/wayland/server.go +2 -1
@@ -54,6 +54,7 @@ type Channels struct {
5454 }
5555 type Server struct {
5656 display *server.Display
57+ env procEnv
5758 name string
5859 cleanup func()
5960 log zerowrap.Logger
@@ -125,7 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125126 d.Close()
126127 return nil, err
127128 }
128- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
129+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
129130 s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}
130131 if opts.Keymap != "" {
131132 fd, size, e := keymapFile(opts.Keymap)
@@ -54,6 +54,7 @@ type Channels struct {
54 }54 }
55 type Server struct {55 type Server struct {
56 display *server.Display56 display *server.Display
57+ env procEnv
57 name string58 name string
58 cleanup func()59 cleanup func()
59 log zerowrap.Logger60 log zerowrap.Logger
@@ -125,7 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125 d.Close()126 d.Close()
126 return nil, err127 return nil, err
127 }128 }
128- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}129+ s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
129 s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}130 s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}
130 if opts.Keymap != "" {131 if opts.Keymap != "" {
131 fd, size, e := keymapFile(opts.Keymap)132 fd, size, e := keymapFile(opts.Keymap)
A internal/adapters/wayland/slot.go +42 -0
@@ -0,0 +1,42 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// procEnv reads one environment variable of a running process.
12+type procEnv interface {
13+ Lookup(pid int, key string) (string, bool)
14+}
15+
16+// linuxProcEnv reads /proc/<pid>/environ: the environment the process started
17+// with. A slot command that forks its window process passes it down, since
18+// children inherit the environment.
19+type linuxProcEnv struct{}
20+
21+func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
22+ if pid <= 0 {
23+ return "", false
24+ }
25+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
26+ if err != nil {
27+ return "", false
28+ }
29+ prefix := []byte(key + "=")
30+ for entry := range bytes.SplitSeq(data, []byte{0}) {
31+ if v, ok := bytes.CutPrefix(entry, prefix); ok {
32+ return string(v), true
33+ }
34+ }
35+ return "", false
36+}
37+
38+// slotToken returns the SlotEnv value of the client process, if any.
39+func (s *Server) slotToken(pid int) string {
40+ v, _ := s.env.Lookup(pid, ports.SlotEnv)
41+ return v
42+}
@@ -0,0 +1,42 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// procEnv reads one environment variable of a running process.
12+type procEnv interface {
13+ Lookup(pid int, key string) (string, bool)
14+}
15+
16+// linuxProcEnv reads /proc/<pid>/environ: the environment the process started
17+// with. A slot command that forks its window process passes it down, since
18+// children inherit the environment.
19+type linuxProcEnv struct{}
20+
21+func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
22+ if pid <= 0 {
23+ return "", false
24+ }
25+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
26+ if err != nil {
27+ return "", false
28+ }
29+ prefix := []byte(key + "=")
30+ for entry := range bytes.SplitSeq(data, []byte{0}) {
31+ if v, ok := bytes.CutPrefix(entry, prefix); ok {
32+ return string(v), true
33+ }
34+ }
35+ return "", false
36+}
37+
38+// slotToken returns the SlotEnv value of the client process, if any.
39+func (s *Server) slotToken(pid int) string {
40+ v, _ := s.env.Lookup(pid, ports.SlotEnv)
41+ return v
42+}
A internal/adapters/wayland/slot_test.go +38 -0
@@ -0,0 +1,38 @@
1+package wayland
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// A mapped window carries the slot token of its client process.
11+func TestWindowMappedCarriesSlot(t *testing.T) {
12+ env := newMockprocEnv(t)
13+ // The test client runs in this process: its PID is ours.
14+ env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15+ env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once()
16+ s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
17+ mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
18+ if w := mapWindow(); w.Slot != "7" {
19+ t.Fatalf("slot %q", w.Slot)
20+ }
21+ if w := mapWindow(); w.Slot != "" {
22+ t.Fatalf("slot %q", w.Slot)
23+ }
24+}
25+
26+func TestLinuxProcEnv(t *testing.T) {
27+ t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env
28+ if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok {
29+ t.Fatal("environ reflects later changes")
30+ }
31+ path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH")
32+ if want, _ := os.LookupEnv("PATH"); ok != (want != "") {
33+ t.Fatalf("PATH %q %v", path, ok)
34+ }
35+ if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok {
36+ t.Fatal("pid 0")
37+ }
38+}
@@ -0,0 +1,38 @@
1+package wayland
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// A mapped window carries the slot token of its client process.
11+func TestWindowMappedCarriesSlot(t *testing.T) {
12+ env := newMockprocEnv(t)
13+ // The test client runs in this process: its PID is ours.
14+ env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once()
15+ env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once()
16+ s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env })
17+ mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
18+ if w := mapWindow(); w.Slot != "7" {
19+ t.Fatalf("slot %q", w.Slot)
20+ }
21+ if w := mapWindow(); w.Slot != "" {
22+ t.Fatalf("slot %q", w.Slot)
23+ }
24+}
25+
26+func TestLinuxProcEnv(t *testing.T) {
27+ t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env
28+ if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok {
29+ t.Fatal("environ reflects later changes")
30+ }
31+ path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH")
32+ if want, _ := os.LookupEnv("PATH"); ok != (want != "") {
33+ t.Fatalf("PATH %q %v", path, ok)
34+ }
35+ if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok {
36+ t.Fatal("pid 0")
37+ }
38+}
M internal/adapters/wayland/xdg.go +3 -2
@@ -150,8 +150,9 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
150150 x.serials = append(x.serials, x.server.serial)
151151 } else if buffer && !w.mapped && x.acked {
152152 w.mapped = true
153- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID})
154- x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped")
153+ slot := x.server.slotToken(r.Client().PID())
154+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})
155+ x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
155156 } else if !buffer && w.mapped {
156157 w.unmap()
157158 }
@@ -150,8 +150,9 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
150 x.serials = append(x.serials, x.server.serial)150 x.serials = append(x.serials, x.server.serial)
151 } else if buffer && !w.mapped && x.acked {151 } else if buffer && !w.mapped && x.acked {
152 w.mapped = true152 w.mapped = true
153- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID})153+ slot := x.server.slotToken(r.Client().PID())
154- x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped")154+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})
155+ x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
155 } else if !buffer && w.mapped {156 } else if !buffer && w.mapped {
156 w.unmap()157 w.unmap()
157 }158 }
M internal/core/core.go +41 -2
@@ -50,6 +50,9 @@ type Core struct {
5050 physW, physH int
5151 scale, cfgScale float64
5252 sentScale ports.SetOutputScale
53+ slots slots
54+ // toSpawn holds slots to start; Run sends them (apply has no context).
55+ toSpawn []slotKey
5356 }
5457
5558 func keyName(s string) string {
@@ -169,6 +172,10 @@ func (c *Core) apply(cfg ports.Config) error {
169172 seen[ws.Name] = true
170173 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171174 }
175+ specs, err := parseSlots(cfg.Workspaces)
176+ if err != nil {
177+ return err
178+ }
172179 c.cfg = cfg
173180 c.binds = binds
174181 c.applyConfigScale()
@@ -177,13 +184,32 @@ func (c *Core) apply(cfg ports.Config) error {
177184 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
178185 c.ws.SetPresets(presets)
179186 c.ws.SetGaps(cfg.Layout.Gaps)
187+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
188+ return nil
189+}
190+
191+// spawnSlots starts pending slot commands, then the empty slots of the
192+// workspace on screen.
193+func (c *Core) spawnSlots(ctx context.Context) error {
194+ keys := append(c.toSpawn, c.refill()...)
195+ c.toSpawn = nil
196+ for _, key := range keys {
197+ if _, ok := c.slots.byKey[key]; !ok {
198+ continue
199+ }
200+ select {
201+ case <-ctx.Done():
202+ return ctx.Err()
203+ case c.ch.Spawn <- c.spawnSlot(key):
204+ }
205+ }
180206 return nil
181207 }
182208 func New(cfg ports.Config, ch Channels) (*Core, error) {
183209 if cap(ch.Scenes) != 1 {
184210 return nil, fmt.Errorf("scenes must have capacity 1")
185211 }
186- c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
212+ c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
187213 if err := c.apply(cfg); err != nil {
188214 return nil, err
189215 }
@@ -334,6 +360,9 @@ func (c *Core) publish(ctx context.Context) error {
334360 return nil
335361 }
336362 func (c *Core) Run(ctx context.Context) error {
363+ if c.spawnSlots(ctx) != nil {
364+ return nil
365+ }
337366 for {
338367 select {
339368 case <-ctx.Done():
@@ -351,9 +380,12 @@ func (c *Core) Run(ctx context.Context) error {
351380 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
352381 c.ws.SetUsable(usable)
353382 case ports.WindowMapped:
354- c.ws.AddWindow(v.ID)
383+ if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {
384+ c.ws.AddWindow(v.ID)
385+ }
355386 case ports.WindowUnmapped:
356387 c.ws.RemoveWindow(v.ID)
388+ c.slotWindowGone(v.ID)
357389 if c.pointer == v.ID {
358390 c.pointer = 0
359391 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -400,6 +432,9 @@ func (c *Core) Run(ctx context.Context) error {
400432 }
401433 continue
402434 }
435+ if c.spawnSlots(ctx) != nil {
436+ return nil
437+ }
403438 if err := c.publish(ctx); err != nil {
404439 return nil
405440 }
@@ -523,6 +558,10 @@ func (c *Core) Run(ctx context.Context) error {
523558 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
524559 }
525560 }
561+ // Showing a declared workspace refills its empty slots.
562+ if c.spawnSlots(ctx) != nil {
563+ return nil
564+ }
526565 if effect.Close != 0 {
527566 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
528567 return nil
@@ -50,6 +50,9 @@ type Core struct {
50 physW, physH int50 physW, physH int
51 scale, cfgScale float6451 scale, cfgScale float64
52 sentScale ports.SetOutputScale52 sentScale ports.SetOutputScale
53+ slots slots
54+ // toSpawn holds slots to start; Run sends them (apply has no context).
55+ toSpawn []slotKey
53 }56 }
54 57
55 func keyName(s string) string {58 func keyName(s string) string {
@@ -169,6 +172,10 @@ func (c *Core) apply(cfg ports.Config) error {
169 seen[ws.Name] = true172 seen[ws.Name] = true
170 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})173 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171 }174 }
175+ specs, err := parseSlots(cfg.Workspaces)
176+ if err != nil {
177+ return err
178+ }
172 c.cfg = cfg179 c.cfg = cfg
173 c.binds = binds180 c.binds = binds
174 c.applyConfigScale()181 c.applyConfigScale()
@@ -177,13 +184,32 @@ func (c *Core) apply(cfg ports.Config) error {
177 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)184 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
178 c.ws.SetPresets(presets)185 c.ws.SetPresets(presets)
179 c.ws.SetGaps(cfg.Layout.Gaps)186 c.ws.SetGaps(cfg.Layout.Gaps)
187+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
188+ return nil
189+}
190+
191+// spawnSlots starts pending slot commands, then the empty slots of the
192+// workspace on screen.
193+func (c *Core) spawnSlots(ctx context.Context) error {
194+ keys := append(c.toSpawn, c.refill()...)
195+ c.toSpawn = nil
196+ for _, key := range keys {
197+ if _, ok := c.slots.byKey[key]; !ok {
198+ continue
199+ }
200+ select {
201+ case <-ctx.Done():
202+ return ctx.Err()
203+ case c.ch.Spawn <- c.spawnSlot(key):
204+ }
205+ }
180 return nil206 return nil
181 }207 }
182 func New(cfg ports.Config, ch Channels) (*Core, error) {208 func New(cfg ports.Config, ch Channels) (*Core, error) {
183 if cap(ch.Scenes) != 1 {209 if cap(ch.Scenes) != 1 {
184 return nil, fmt.Errorf("scenes must have capacity 1")210 return nil, fmt.Errorf("scenes must have capacity 1")
185 }211 }
186- c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}212+ c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
187 if err := c.apply(cfg); err != nil {213 if err := c.apply(cfg); err != nil {
188 return nil, err214 return nil, err
189 }215 }
@@ -334,6 +360,9 @@ func (c *Core) publish(ctx context.Context) error {
334 return nil360 return nil
335 }361 }
336 func (c *Core) Run(ctx context.Context) error {362 func (c *Core) Run(ctx context.Context) error {
363+ if c.spawnSlots(ctx) != nil {
364+ return nil
365+ }
337 for {366 for {
338 select {367 select {
339 case <-ctx.Done():368 case <-ctx.Done():
@@ -351,9 +380,12 @@ func (c *Core) Run(ctx context.Context) error {
351 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)380 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
352 c.ws.SetUsable(usable)381 c.ws.SetUsable(usable)
353 case ports.WindowMapped:382 case ports.WindowMapped:
354- c.ws.AddWindow(v.ID)383+ if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {
384+ c.ws.AddWindow(v.ID)
385+ }
355 case ports.WindowUnmapped:386 case ports.WindowUnmapped:
356 c.ws.RemoveWindow(v.ID)387 c.ws.RemoveWindow(v.ID)
388+ c.slotWindowGone(v.ID)
357 if c.pointer == v.ID {389 if c.pointer == v.ID {
358 c.pointer = 0390 c.pointer = 0
359 if err := c.command(ctx, ports.PointerFocus{}); err != nil {391 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -400,6 +432,9 @@ func (c *Core) Run(ctx context.Context) error {
400 }432 }
401 continue433 continue
402 }434 }
435+ if c.spawnSlots(ctx) != nil {
436+ return nil
437+ }
403 if err := c.publish(ctx); err != nil {438 if err := c.publish(ctx); err != nil {
404 return nil439 return nil
405 }440 }
@@ -523,6 +558,10 @@ func (c *Core) Run(ctx context.Context) error {
523 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:558 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
524 }559 }
525 }560 }
561+ // Showing a declared workspace refills its empty slots.
562+ if c.spawnSlots(ctx) != nil {
563+ return nil
564+ }
526 if effect.Close != 0 {565 if effect.Close != 0 {
527 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {566 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
528 return nil567 return nil
M internal/core/layout.go +47 -0
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "github.com/bnema/nefertty/internal/ports"
66 "math/bits"
7+ "slices"
78 "strconv"
89 "strings"
910 )
@@ -23,6 +24,11 @@ func ParseWidth(s string) (Width, error) {
2324 if e == nil && n > 0 {
2425 return Width{Pixels: n}, nil
2526 }
27+ } else if strings.HasSuffix(s, "%") {
28+ n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
29+ if e == nil && n > 0 && n <= 100 {
30+ return Width{Num: n, Den: 100}, nil
31+ }
2632 } else {
2733 p := strings.Split(s, "/")
2834 if len(p) == 2 {
@@ -76,6 +82,9 @@ type Column struct {
7682 Windows []WindowID
7783 Width Width
7884 Focus int
85+ // Slot is the declared column number (workspace.<name>.column.N) of a
86+ // slot window; 0 for normal columns.
87+ Slot int
7988 }
8089 type Placement struct {
8190 ID WindowID
@@ -154,6 +163,44 @@ func (w *Workspace) AddWindow(id WindowID) {
154163 w.Focus = at
155164 w.scroll()
156165 }
166+
167+// AddSlotWindow places a slot window in its own column, ordered by slot
168+// number before the other columns. Focus stays on the focused window: slot
169+// windows are automatic events (ADR 011 golden rule).
170+func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
171+ if id == 0 || w.has(id) {
172+ return
173+ }
174+ at := 0
175+ for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot {
176+ at++
177+ }
178+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
179+ if len(w.Columns) > 1 && at <= w.Focus {
180+ w.Focus++
181+ }
182+ w.scroll()
183+}
184+
185+// setSlotWidth updates the width of slot column n, if present.
186+func (w *Workspace) setSlotWidth(n int, width Width) {
187+ for i := range w.Columns {
188+ if w.Columns[i].Slot == n {
189+ w.Columns[i].Width = width
190+ }
191+ }
192+ w.scroll()
193+}
194+
195+// unslot turns slot column n into a normal column.
196+func (w *Workspace) unslot(n int) {
197+ for i := range w.Columns {
198+ if w.Columns[i].Slot == n {
199+ w.Columns[i].Slot = 0
200+ }
201+ }
202+}
203+
157204 func (w *Workspace) RemoveWindow(id WindowID) {
158205 for i := range w.Columns {
159206 for j, v := range w.Columns[i].Windows {
@@ -4,6 +4,7 @@ import (
4 "fmt"4 "fmt"
5 "github.com/bnema/nefertty/internal/ports"5 "github.com/bnema/nefertty/internal/ports"
6 "math/bits"6 "math/bits"
7+ "slices"
7 "strconv"8 "strconv"
8 "strings"9 "strings"
9 )10 )
@@ -23,6 +24,11 @@ func ParseWidth(s string) (Width, error) {
23 if e == nil && n > 0 {24 if e == nil && n > 0 {
24 return Width{Pixels: n}, nil25 return Width{Pixels: n}, nil
25 }26 }
27+ } else if strings.HasSuffix(s, "%") {
28+ n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
29+ if e == nil && n > 0 && n <= 100 {
30+ return Width{Num: n, Den: 100}, nil
31+ }
26 } else {32 } else {
27 p := strings.Split(s, "/")33 p := strings.Split(s, "/")
28 if len(p) == 2 {34 if len(p) == 2 {
@@ -76,6 +82,9 @@ type Column struct {
76 Windows []WindowID82 Windows []WindowID
77 Width Width83 Width Width
78 Focus int84 Focus int
85+ // Slot is the declared column number (workspace.<name>.column.N) of a
86+ // slot window; 0 for normal columns.
87+ Slot int
79 }88 }
80 type Placement struct {89 type Placement struct {
81 ID WindowID90 ID WindowID
@@ -154,6 +163,44 @@ func (w *Workspace) AddWindow(id WindowID) {
154 w.Focus = at163 w.Focus = at
155 w.scroll()164 w.scroll()
156 }165 }
166+
167+// AddSlotWindow places a slot window in its own column, ordered by slot
168+// number before the other columns. Focus stays on the focused window: slot
169+// windows are automatic events (ADR 011 golden rule).
170+func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
171+ if id == 0 || w.has(id) {
172+ return
173+ }
174+ at := 0
175+ for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot {
176+ at++
177+ }
178+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
179+ if len(w.Columns) > 1 && at <= w.Focus {
180+ w.Focus++
181+ }
182+ w.scroll()
183+}
184+
185+// setSlotWidth updates the width of slot column n, if present.
186+func (w *Workspace) setSlotWidth(n int, width Width) {
187+ for i := range w.Columns {
188+ if w.Columns[i].Slot == n {
189+ w.Columns[i].Width = width
190+ }
191+ }
192+ w.scroll()
193+}
194+
195+// unslot turns slot column n into a normal column.
196+func (w *Workspace) unslot(n int) {
197+ for i := range w.Columns {
198+ if w.Columns[i].Slot == n {
199+ w.Columns[i].Slot = 0
200+ }
201+ }
202+}
203+
157 func (w *Workspace) RemoveWindow(id WindowID) {204 func (w *Workspace) RemoveWindow(id WindowID) {
158 for i := range w.Columns {205 for i := range w.Columns {
159 for j, v := range w.Columns[i].Windows {206 for j, v := range w.Columns[i].Windows {
M internal/core/monitor.go +11 -6
@@ -92,12 +92,7 @@ func (m *Monitor) show(w *Workspace) {
9292 // ToggleNamed shows the named workspace, or returns to the previous one when
9393 // it is already on screen. Unknown names do nothing.
9494 func (m *Monitor) ToggleNamed(name string) {
95- var target *Workspace
96- for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
97- if w.Name == name {
98- target = w
99- }
100- }
95+ target := m.byName(name)
10196 switch cur := m.Current(); {
10297 case target == nil:
10398 case target != cur:
@@ -114,6 +109,16 @@ func (m *Monitor) ToggleNamed(name string) {
114109 }
115110 }
116111
112+// byName returns the configured workspace with that name, or nil.
113+func (m *Monitor) byName(name string) *Workspace {
114+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
115+ if w.Name == name {
116+ return w
117+ }
118+ }
119+ return nil
120+}
121+
117122 func indexOf(list []*Workspace, w *Workspace) int {
118123 for i, v := range list {
119124 if v == w {
@@ -92,12 +92,7 @@ func (m *Monitor) show(w *Workspace) {
92 // ToggleNamed shows the named workspace, or returns to the previous one when92 // ToggleNamed shows the named workspace, or returns to the previous one when
93 // it is already on screen. Unknown names do nothing.93 // it is already on screen. Unknown names do nothing.
94 func (m *Monitor) ToggleNamed(name string) {94 func (m *Monitor) ToggleNamed(name string) {
95- var target *Workspace95+ target := m.byName(name)
96- for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
97- if w.Name == name {
98- target = w
99- }
100- }
101 switch cur := m.Current(); {96 switch cur := m.Current(); {
102 case target == nil:97 case target == nil:
103 case target != cur:98 case target != cur:
@@ -114,6 +109,16 @@ func (m *Monitor) ToggleNamed(name string) {
114 }109 }
115 }110 }
116 111
112+// byName returns the configured workspace with that name, or nil.
113+func (m *Monitor) byName(name string) *Workspace {
114+ for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) {
115+ if w.Name == name {
116+ return w
117+ }
118+ }
119+ return nil
120+}
121+
117 func indexOf(list []*Workspace, w *Workspace) int {122 func indexOf(list []*Workspace, w *Workspace) int {
118 for i, v := range list {123 for i, v := range list {
119 if v == w {124 if v == w {
A internal/core/slots.go +176 -0
@@ -0,0 +1,176 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves
12+// column N for the window of one command. The slot, not the window, has the
13+// identity: nefertty spawns the command with SlotEnv set to a token, and the
14+// first window of a client carrying that token fills the slot.
15+//
16+// A slot is spawned at startup and when its workspace is shown while it is
17+// empty and no spawn is pending. Nothing is relaunched on its own.
18+
19+type slotKey struct {
20+ workspace string
21+ index int
22+}
23+
24+type slotState struct {
25+ argv []string
26+ width Width
27+ token string // SlotEnv value of the last spawn; "" before the first
28+ window WindowID // 0 while empty
29+ pending bool // spawned, window not mapped yet
30+}
31+
32+type slots struct {
33+ byKey map[slotKey]*slotState
34+ // byToken maps spawn tokens (current and past) to their slot, so a
35+ // late window of an old spawn still lands in its slot.
36+ byToken map[string]slotKey
37+ next uint64
38+}
39+
40+func newSlots() slots {
41+ return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}}
42+}
43+
44+// slotSpec is one parsed slot of the config.
45+type slotSpec struct {
46+ key slotKey
47+ argv []string
48+ width Width
49+}
50+
51+func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
52+ var specs []slotSpec
53+ for _, w := range ws {
54+ for _, s := range w.Slots {
55+ width, err := ParseWidth(s.Width)
56+ if err != nil {
57+ return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
58+ }
59+ if len(s.Argv) == 0 || s.Index < 1 {
60+ return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
61+ }
62+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
63+ }
64+ }
65+ return specs, nil
66+}
67+
68+// updateSlots applies the config. It returns the new slots, which the caller
69+// spawns; changed commands are respawned only when their slot is empty.
70+// A slot dropped from config keeps its window as a normal column.
71+func (c *Core) updateSlots(specs []slotSpec) []slotKey {
72+ want := map[slotKey]slotSpec{}
73+ for _, s := range specs {
74+ want[s.key] = s
75+ }
76+ for key := range c.slots.byKey {
77+ if _, ok := want[key]; ok {
78+ continue
79+ }
80+ if w := c.ws.byName(key.workspace); w != nil {
81+ w.unslot(key.index)
82+ }
83+ c.forgetTokens(key)
84+ delete(c.slots.byKey, key)
85+ }
86+ var spawn []slotKey
87+ for _, s := range specs {
88+ st, ok := c.slots.byKey[s.key]
89+ if !ok {
90+ c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width}
91+ spawn = append(spawn, s.key)
92+ continue
93+ }
94+ if st.width != s.width {
95+ st.width = s.width
96+ if w := c.ws.byName(s.key.workspace); w != nil {
97+ w.setSlotWidth(s.key.index, s.width)
98+ }
99+ }
100+ if !slices.Equal(st.argv, s.argv) {
101+ st.argv = s.argv
102+ if st.window == 0 {
103+ // A pending spawn ran the old command: its window is no
104+ // longer the slot's; the new command replaces it.
105+ c.forgetTokens(s.key)
106+ st.pending = false
107+ spawn = append(spawn, s.key)
108+ }
109+ }
110+ }
111+ return spawn
112+}
113+
114+// forgetTokens drops the spawn tokens of a slot: their windows open as normal ones.
115+func (c *Core) forgetTokens(key slotKey) {
116+ for tok, k := range c.slots.byToken {
117+ if k == key {
118+ delete(c.slots.byToken, tok)
119+ }
120+ }
121+}
122+
123+// spawnSlot starts the slot's command with a fresh token.
124+func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
125+ st := c.slots.byKey[key]
126+ c.slots.next++
127+ st.token = strconv.FormatUint(c.slots.next, 10)
128+ st.pending = true
129+ c.slots.byToken[st.token] = key
130+ return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
131+}
132+
133+// placeSlotWindow puts a mapped window with a slot token into its slot. It
134+// returns false when the window is not a slot window (unknown token, slot
135+// already filled, or its workspace is gone): it then follows normal rules.
136+func (c *Core) placeSlotWindow(id WindowID, token string) bool {
137+ key, ok := c.slots.byToken[token]
138+ if !ok {
139+ return false
140+ }
141+ st := c.slots.byKey[key]
142+ w := c.ws.byName(key.workspace)
143+ if st == nil || st.window != 0 || w == nil {
144+ return false
145+ }
146+ st.window, st.pending = id, false
147+ w.AddSlotWindow(id, key.index, st.width)
148+ c.ws.normalize()
149+ return true
150+}
151+
152+// slotWindowGone empties the slot of a closed window. The slot stays
153+// reserved; it is refilled when the user next shows its workspace.
154+func (c *Core) slotWindowGone(id WindowID) {
155+ for _, st := range c.slots.byKey {
156+ if st.window == id {
157+ st.window = 0
158+ }
159+ }
160+}
161+
162+// refill returns the empty, idle slots of the workspace on screen.
163+func (c *Core) refill() []slotKey {
164+ name := c.ws.Current().Name
165+ if name == "" {
166+ return nil
167+ }
168+ var keys []slotKey
169+ for key, st := range c.slots.byKey {
170+ if key.workspace == name && st.window == 0 && !st.pending {
171+ keys = append(keys, key)
172+ }
173+ }
174+ slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
175+ return keys
176+}
@@ -0,0 +1,176 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves
12+// column N for the window of one command. The slot, not the window, has the
13+// identity: nefertty spawns the command with SlotEnv set to a token, and the
14+// first window of a client carrying that token fills the slot.
15+//
16+// A slot is spawned at startup and when its workspace is shown while it is
17+// empty and no spawn is pending. Nothing is relaunched on its own.
18+
19+type slotKey struct {
20+ workspace string
21+ index int
22+}
23+
24+type slotState struct {
25+ argv []string
26+ width Width
27+ token string // SlotEnv value of the last spawn; "" before the first
28+ window WindowID // 0 while empty
29+ pending bool // spawned, window not mapped yet
30+}
31+
32+type slots struct {
33+ byKey map[slotKey]*slotState
34+ // byToken maps spawn tokens (current and past) to their slot, so a
35+ // late window of an old spawn still lands in its slot.
36+ byToken map[string]slotKey
37+ next uint64
38+}
39+
40+func newSlots() slots {
41+ return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}}
42+}
43+
44+// slotSpec is one parsed slot of the config.
45+type slotSpec struct {
46+ key slotKey
47+ argv []string
48+ width Width
49+}
50+
51+func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
52+ var specs []slotSpec
53+ for _, w := range ws {
54+ for _, s := range w.Slots {
55+ width, err := ParseWidth(s.Width)
56+ if err != nil {
57+ return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
58+ }
59+ if len(s.Argv) == 0 || s.Index < 1 {
60+ return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
61+ }
62+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
63+ }
64+ }
65+ return specs, nil
66+}
67+
68+// updateSlots applies the config. It returns the new slots, which the caller
69+// spawns; changed commands are respawned only when their slot is empty.
70+// A slot dropped from config keeps its window as a normal column.
71+func (c *Core) updateSlots(specs []slotSpec) []slotKey {
72+ want := map[slotKey]slotSpec{}
73+ for _, s := range specs {
74+ want[s.key] = s
75+ }
76+ for key := range c.slots.byKey {
77+ if _, ok := want[key]; ok {
78+ continue
79+ }
80+ if w := c.ws.byName(key.workspace); w != nil {
81+ w.unslot(key.index)
82+ }
83+ c.forgetTokens(key)
84+ delete(c.slots.byKey, key)
85+ }
86+ var spawn []slotKey
87+ for _, s := range specs {
88+ st, ok := c.slots.byKey[s.key]
89+ if !ok {
90+ c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width}
91+ spawn = append(spawn, s.key)
92+ continue
93+ }
94+ if st.width != s.width {
95+ st.width = s.width
96+ if w := c.ws.byName(s.key.workspace); w != nil {
97+ w.setSlotWidth(s.key.index, s.width)
98+ }
99+ }
100+ if !slices.Equal(st.argv, s.argv) {
101+ st.argv = s.argv
102+ if st.window == 0 {
103+ // A pending spawn ran the old command: its window is no
104+ // longer the slot's; the new command replaces it.
105+ c.forgetTokens(s.key)
106+ st.pending = false
107+ spawn = append(spawn, s.key)
108+ }
109+ }
110+ }
111+ return spawn
112+}
113+
114+// forgetTokens drops the spawn tokens of a slot: their windows open as normal ones.
115+func (c *Core) forgetTokens(key slotKey) {
116+ for tok, k := range c.slots.byToken {
117+ if k == key {
118+ delete(c.slots.byToken, tok)
119+ }
120+ }
121+}
122+
123+// spawnSlot starts the slot's command with a fresh token.
124+func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
125+ st := c.slots.byKey[key]
126+ c.slots.next++
127+ st.token = strconv.FormatUint(c.slots.next, 10)
128+ st.pending = true
129+ c.slots.byToken[st.token] = key
130+ return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
131+}
132+
133+// placeSlotWindow puts a mapped window with a slot token into its slot. It
134+// returns false when the window is not a slot window (unknown token, slot
135+// already filled, or its workspace is gone): it then follows normal rules.
136+func (c *Core) placeSlotWindow(id WindowID, token string) bool {
137+ key, ok := c.slots.byToken[token]
138+ if !ok {
139+ return false
140+ }
141+ st := c.slots.byKey[key]
142+ w := c.ws.byName(key.workspace)
143+ if st == nil || st.window != 0 || w == nil {
144+ return false
145+ }
146+ st.window, st.pending = id, false
147+ w.AddSlotWindow(id, key.index, st.width)
148+ c.ws.normalize()
149+ return true
150+}
151+
152+// slotWindowGone empties the slot of a closed window. The slot stays
153+// reserved; it is refilled when the user next shows its workspace.
154+func (c *Core) slotWindowGone(id WindowID) {
155+ for _, st := range c.slots.byKey {
156+ if st.window == id {
157+ st.window = 0
158+ }
159+ }
160+}
161+
162+// refill returns the empty, idle slots of the workspace on screen.
163+func (c *Core) refill() []slotKey {
164+ name := c.ws.Current().Name
165+ if name == "" {
166+ return nil
167+ }
168+ var keys []slotKey
169+ for key, st := range c.slots.byKey {
170+ if key.workspace == name && st.window == 0 && !st.pending {
171+ keys = append(keys, key)
172+ }
173+ }
174+ slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
175+ return keys
176+}
A internal/core/slots_test.go +227 -0
@@ -0,0 +1,227 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/adapters/config"
11+ "github.com/bnema/nefertty/internal/core"
12+ "github.com/bnema/nefertty/internal/ports"
13+)
14+
15+type slotRig struct {
16+ client chan ports.ClientEvent
17+ input chan ports.InputEvent
18+ reload chan ports.ConfigChanged
19+ spawn chan ports.SpawnRequest
20+ scenes chan ports.Scene
21+ errs chan error
22+ cfg ports.Config
23+}
24+
25+// startSlots runs core with a hidden "dev" workspace of two slots, toggled
26+// by Alt+d, on a 100x80 output.
27+func startSlots(t *testing.T) *slotRig {
28+ t.Helper()
29+ cfg := config.Defaults()
30+ cfg.Keyboard.CmdKey = "alt"
31+ cfg.Border.Width = 0
32+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{
33+ {Index: 1, Width: "70%", Argv: []string{"code"}},
34+ {Index: 2, Width: "30%", Argv: []string{"foot"}},
35+ }}}
36+ cfg.Binds["Alt+d"] = "workspace dev"
37+ r := &slotRig{
38+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39+ reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
40+ scenes: make(chan ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,
41+ }
42+ output := make(chan ports.OutputEvent, 1)
43+ commands := make(chan ports.ClientCommand, 1024)
44+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs})
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- c.Run(ctx) }()
51+ t.Cleanup(func() { cancel(); <-done })
52+ go func() { // commands are not under test
53+ for range commands {
54+ }
55+ }()
56+ output <- ports.OutputMode{Width: 100, Height: 80}
57+ receive(t, r.scenes)
58+ return r
59+}
60+
61+// token returns the SlotEnv value of a spawn request.
62+func token(t *testing.T, req ports.SpawnRequest) string {
63+ t.Helper()
64+ for _, e := range req.Env {
65+ if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok {
66+ return v
67+ }
68+ }
69+ t.Fatalf("no %s in %v", ports.SlotEnv, req.Env)
70+ return ""
71+}
72+
73+func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
74+ t.Helper()
75+ select {
76+ case v := <-ch:
77+ t.Fatalf("unexpected spawn %v", v.Argv)
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+}
81+
82+// scene waits for a scene matching ok.
83+func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
84+ t.Helper()
85+ deadline := time.After(time.Second)
86+ for {
87+ select {
88+ case s := <-ch:
89+ if ok(s) {
90+ return s
91+ }
92+ case <-deadline:
93+ t.Fatal("no matching scene")
94+ }
95+ }
96+}
97+
98+func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
99+ m := map[ports.WindowID]ports.Rect{}
100+ for _, w := range s.Windows {
101+ if !w.Hidden {
102+ m[w.ID] = w.Rect
103+ }
104+ }
105+ return m
106+}
107+
108+func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
109+ r := startSlots(t)
110+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
111+ if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
112+ t.Fatal(code, foot)
113+ }
114+ // A normal window on workspace 1 keeps focus while slot windows map.
115+ r.client <- ports.WindowMapped{ID: 1}
116+ // foot maps first; code's window still goes left of it.
117+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
118+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
119+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
120+ for _, w := range s.Windows {
121+ if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
122+ t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
123+ }
124+ }
125+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
126+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
127+ got := visible(s)
128+ if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
129+ t.Fatal(got)
130+ }
131+ noSpawn(t, r.spawn) // full slots are not respawned
132+}
133+
134+func TestSlotRefilledOnlyWhenShown(t *testing.T) {
135+ r := startSlots(t)
136+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
137+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
138+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
139+ r.client <- ports.WindowUnmapped{ID: 3} // foot exits
140+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
141+ noSpawn(t, r.spawn) // nothing relaunches on its own
142+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
143+ again := receive(t, r.spawn)
144+ if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
145+ t.Fatal(again)
146+ }
147+ r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
148+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
149+ if got := visible(s); got[4].X != 70 {
150+ t.Fatal(got)
151+ }
152+ // Leaving and showing dev again with full slots spawns nothing.
153+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
154+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
155+ noSpawn(t, r.spawn)
156+}
157+
158+func TestSlotExtraWindowsAreNormal(t *testing.T) {
159+ r := startSlots(t)
160+ code := receive(t, r.spawn)
161+ receive(t, r.spawn)
162+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
163+ // A second window of the same process: the slot is taken, normal rules.
164+ r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
165+ r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
166+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
167+ if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
168+ t.Fatalf("extra windows not on the workspace on screen: %v", got)
169+ }
170+}
171+
172+func TestSlotReload(t *testing.T) {
173+ r := startSlots(t)
174+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
175+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
176+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
177+ cfg := r.cfg
178+ ws := cfg.Workspaces[0]
179+ ws.Slots = []ports.SlotConfig{
180+ {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
181+ {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
182+ {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
183+ }
184+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
185+ r.reload <- ports.ConfigChanged{Config: cfg}
186+ var argv []string
187+ for range 2 {
188+ argv = append(argv, receive(t, r.spawn).Argv[0])
189+ }
190+ slices.Sort(argv)
191+ if !slices.Equal(argv, []string{"btop", "kitty"}) {
192+ t.Fatal(argv)
193+ }
194+ noSpawn(t, r.spawn)
195+ // The old foot spawn no longer fills slot 2 (its command changed): it
196+ // opens as a normal window on workspace 1, on screen.
197+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
198+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
199+ if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
200+ t.Fatal(got)
201+ }
202+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
203+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
204+ if got := visible(s); got[2].W != 50 {
205+ t.Fatal(got)
206+ }
207+ // Dropping the workspace's slots spawns nothing and keeps the window.
208+ ws.Slots = nil
209+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
210+ r.reload <- ports.ConfigChanged{Config: cfg}
211+ noSpawn(t, r.spawn)
212+}
213+
214+func TestInvalidSlotWidthRejected(t *testing.T) {
215+ r := startSlots(t)
216+ receive(t, r.spawn)
217+ receive(t, r.spawn)
218+ cfg := r.cfg
219+ ws := cfg.Workspaces[0]
220+ ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
221+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
222+ r.reload <- ports.ConfigChanged{Config: cfg}
223+ if err := receive(t, r.errs); err == nil {
224+ t.Fatal("no error")
225+ }
226+ noSpawn(t, r.spawn)
227+}
@@ -0,0 +1,227 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/adapters/config"
11+ "github.com/bnema/nefertty/internal/core"
12+ "github.com/bnema/nefertty/internal/ports"
13+)
14+
15+type slotRig struct {
16+ client chan ports.ClientEvent
17+ input chan ports.InputEvent
18+ reload chan ports.ConfigChanged
19+ spawn chan ports.SpawnRequest
20+ scenes chan ports.Scene
21+ errs chan error
22+ cfg ports.Config
23+}
24+
25+// startSlots runs core with a hidden "dev" workspace of two slots, toggled
26+// by Alt+d, on a 100x80 output.
27+func startSlots(t *testing.T) *slotRig {
28+ t.Helper()
29+ cfg := config.Defaults()
30+ cfg.Keyboard.CmdKey = "alt"
31+ cfg.Border.Width = 0
32+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{
33+ {Index: 1, Width: "70%", Argv: []string{"code"}},
34+ {Index: 2, Width: "30%", Argv: []string{"foot"}},
35+ }}}
36+ cfg.Binds["Alt+d"] = "workspace dev"
37+ r := &slotRig{
38+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39+ reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
40+ scenes: make(chan ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,
41+ }
42+ output := make(chan ports.OutputEvent, 1)
43+ commands := make(chan ports.ClientCommand, 1024)
44+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs})
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- c.Run(ctx) }()
51+ t.Cleanup(func() { cancel(); <-done })
52+ go func() { // commands are not under test
53+ for range commands {
54+ }
55+ }()
56+ output <- ports.OutputMode{Width: 100, Height: 80}
57+ receive(t, r.scenes)
58+ return r
59+}
60+
61+// token returns the SlotEnv value of a spawn request.
62+func token(t *testing.T, req ports.SpawnRequest) string {
63+ t.Helper()
64+ for _, e := range req.Env {
65+ if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok {
66+ return v
67+ }
68+ }
69+ t.Fatalf("no %s in %v", ports.SlotEnv, req.Env)
70+ return ""
71+}
72+
73+func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
74+ t.Helper()
75+ select {
76+ case v := <-ch:
77+ t.Fatalf("unexpected spawn %v", v.Argv)
78+ case <-time.After(50 * time.Millisecond):
79+ }
80+}
81+
82+// scene waits for a scene matching ok.
83+func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
84+ t.Helper()
85+ deadline := time.After(time.Second)
86+ for {
87+ select {
88+ case s := <-ch:
89+ if ok(s) {
90+ return s
91+ }
92+ case <-deadline:
93+ t.Fatal("no matching scene")
94+ }
95+ }
96+}
97+
98+func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
99+ m := map[ports.WindowID]ports.Rect{}
100+ for _, w := range s.Windows {
101+ if !w.Hidden {
102+ m[w.ID] = w.Rect
103+ }
104+ }
105+ return m
106+}
107+
108+func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
109+ r := startSlots(t)
110+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
111+ if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
112+ t.Fatal(code, foot)
113+ }
114+ // A normal window on workspace 1 keeps focus while slot windows map.
115+ r.client <- ports.WindowMapped{ID: 1}
116+ // foot maps first; code's window still goes left of it.
117+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
118+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
119+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
120+ for _, w := range s.Windows {
121+ if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
122+ t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
123+ }
124+ }
125+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
126+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
127+ got := visible(s)
128+ if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
129+ t.Fatal(got)
130+ }
131+ noSpawn(t, r.spawn) // full slots are not respawned
132+}
133+
134+func TestSlotRefilledOnlyWhenShown(t *testing.T) {
135+ r := startSlots(t)
136+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
137+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
138+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
139+ r.client <- ports.WindowUnmapped{ID: 3} // foot exits
140+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
141+ noSpawn(t, r.spawn) // nothing relaunches on its own
142+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
143+ again := receive(t, r.spawn)
144+ if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
145+ t.Fatal(again)
146+ }
147+ r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
148+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
149+ if got := visible(s); got[4].X != 70 {
150+ t.Fatal(got)
151+ }
152+ // Leaving and showing dev again with full slots spawns nothing.
153+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
154+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
155+ noSpawn(t, r.spawn)
156+}
157+
158+func TestSlotExtraWindowsAreNormal(t *testing.T) {
159+ r := startSlots(t)
160+ code := receive(t, r.spawn)
161+ receive(t, r.spawn)
162+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
163+ // A second window of the same process: the slot is taken, normal rules.
164+ r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
165+ r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
166+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
167+ if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
168+ t.Fatalf("extra windows not on the workspace on screen: %v", got)
169+ }
170+}
171+
172+func TestSlotReload(t *testing.T) {
173+ r := startSlots(t)
174+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
175+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
176+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
177+ cfg := r.cfg
178+ ws := cfg.Workspaces[0]
179+ ws.Slots = []ports.SlotConfig{
180+ {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
181+ {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
182+ {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
183+ }
184+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
185+ r.reload <- ports.ConfigChanged{Config: cfg}
186+ var argv []string
187+ for range 2 {
188+ argv = append(argv, receive(t, r.spawn).Argv[0])
189+ }
190+ slices.Sort(argv)
191+ if !slices.Equal(argv, []string{"btop", "kitty"}) {
192+ t.Fatal(argv)
193+ }
194+ noSpawn(t, r.spawn)
195+ // The old foot spawn no longer fills slot 2 (its command changed): it
196+ // opens as a normal window on workspace 1, on screen.
197+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
198+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
199+ if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
200+ t.Fatal(got)
201+ }
202+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
203+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
204+ if got := visible(s); got[2].W != 50 {
205+ t.Fatal(got)
206+ }
207+ // Dropping the workspace's slots spawns nothing and keeps the window.
208+ ws.Slots = nil
209+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
210+ r.reload <- ports.ConfigChanged{Config: cfg}
211+ noSpawn(t, r.spawn)
212+}
213+
214+func TestInvalidSlotWidthRejected(t *testing.T) {
215+ r := startSlots(t)
216+ receive(t, r.spawn)
217+ receive(t, r.spawn)
218+ cfg := r.cfg
219+ ws := cfg.Workspaces[0]
220+ ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
221+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
222+ r.reload <- ports.ConfigChanged{Config: cfg}
223+ if err := receive(t, r.errs); err == nil {
224+ t.Fatal("no error")
225+ }
226+ noSpawn(t, r.spawn)
227+}
M internal/ports/config.go +12 -1
@@ -59,8 +59,19 @@ type Config struct {
5959 // only reachable through a `workspace <name>` bind, which toggles them.
6060 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
6161 type WorkspaceConfig struct {
62- Name string
62+ Name string
63+ // Slots are the declared columns (workspace.<name>.column.N), by N.
64+ Slots []SlotConfig
6365 Hidden bool
6466 MaxColumns int
6567 Overflow string
6668 }
69+
70+// SlotConfig reserves column N of a workspace for the window of one command.
71+// nefertty spawns the command at startup; the window it opens goes to the
72+// slot. Width is a layout width (fraction, percentage or pixels).
73+type SlotConfig struct {
74+ Index int
75+ Width string
76+ Argv []string
77+}
@@ -59,8 +59,19 @@ type Config struct {
59 // only reachable through a `workspace <name>` bind, which toggles them.59 // only reachable through a `workspace <name>` bind, which toggles them.
60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
61 type WorkspaceConfig struct {61 type WorkspaceConfig struct {
62- Name string62+ Name string
63+ // Slots are the declared columns (workspace.<name>.column.N), by N.
64+ Slots []SlotConfig
63 Hidden bool65 Hidden bool
64 MaxColumns int66 MaxColumns int
65 Overflow string67 Overflow string
66 }68 }
69+
70+// SlotConfig reserves column N of a workspace for the window of one command.
71+// nefertty spawns the command at startup; the window it opens goes to the
72+// slot. Width is a layout width (fraction, percentage or pixels).
73+type SlotConfig struct {
74+ Index int
75+ Width string
76+ Argv []string
77+}
M internal/ports/messages.go +11 -1
@@ -7,8 +7,14 @@ type ClientEvent interface{ clientEvent() }
77 type WindowMapped struct {
88 ID WindowID
99 AppID string
10+ // Slot is the SlotEnv value of the client process, empty if none.
11+ Slot string
1012 }
1113
14+// SlotEnv is the environment variable nefertty sets on processes it spawns
15+// for a slot. The client's value maps its windows to the slot.
16+const SlotEnv = "NEFERTTY_SLOT"
17+
1218 func (WindowMapped) clientEvent() {}
1319
1420 // WindowUnmapped carries wayland → core removal.
@@ -214,7 +220,11 @@ type CursorImage struct {
214220 }
215221
216222 // SpawnRequest carries core → launcher process arguments.
217-type SpawnRequest struct{ Argv []string }
223+type SpawnRequest struct {
224+ Argv []string
225+ // Env adds KEY=value entries to the child environment.
226+ Env []string
227+}
218228
219229 // Scene carries core → renderer immutable snapshots with fresh Windows slices.
220230 // Rects and the output size are logical; the renderer multiplies by Scale.
@@ -7,8 +7,14 @@ type ClientEvent interface{ clientEvent() }
7 type WindowMapped struct {7 type WindowMapped struct {
8 ID WindowID8 ID WindowID
9 AppID string9 AppID string
10+ // Slot is the SlotEnv value of the client process, empty if none.
11+ Slot string
10 }12 }
11 13
14+// SlotEnv is the environment variable nefertty sets on processes it spawns
15+// for a slot. The client's value maps its windows to the slot.
16+const SlotEnv = "NEFERTTY_SLOT"
17+
12 func (WindowMapped) clientEvent() {}18 func (WindowMapped) clientEvent() {}
13 19
14 // WindowUnmapped carries wayland → core removal.20 // WindowUnmapped carries wayland → core removal.
@@ -214,7 +220,11 @@ type CursorImage struct {
214 }220 }
215 221
216 // SpawnRequest carries core → launcher process arguments.222 // SpawnRequest carries core → launcher process arguments.
217-type SpawnRequest struct{ Argv []string }223+type SpawnRequest struct {
224+ Argv []string
225+ // Env adds KEY=value entries to the child environment.
226+ Env []string
227+}
218 228
219 // Scene carries core → renderer immutable snapshots with fresh Windows slices.229 // Scene carries core → renderer immutable snapshots with fresh Windows slices.
220 // Rects and the output size are logical; the renderer multiplies by Scale.230 // Rects and the output size are logical; the renderer multiplies by Scale.

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 26

Tags 10

↑↓ moveenter openesc close