detached 6b4711d

merge: slots (declarative workspace columns spawned in the background)

commit
6b4711d8a1ea254c419842cb2fbb845816345f97
author
brice · · 2026-09-24 18:13 UTC
merge of
f2678e8 + 9f4c5da · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1038 -56
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 +10 -2
@@ -35,9 +35,17 @@ layout.presets = 1/3, 1/2, 2/3, 1
3535 # when empty. hidden = on keeps one out of the numbers and of up/down: only its
3636 # bind shows it, and pressing the bind again returns to the previous workspace.
3737 # max-columns and overflow override the layout.* values.
38+# workspace.web.max-columns = 3
39+# workspace.web.overflow = fixed
40+#
41+# Slots: column.N = <width>, <command>. nefertty starts each command in the
42+# background and puts its first window in column N, without taking focus.
43+# A closed slot stays empty until the workspace is shown again. Apps that
44+# reuse a running instance need a new-instance flag. Widths need
45+# overflow = scroll.
3846 # workspace.dev.hidden = on
39-# workspace.dev.max-columns = 3
40-# workspace.dev.overflow = fixed
47+# workspace.dev.column.1 = 67%, code --new-window
48+# workspace.dev.column.2 = 33%, foot
4149 # bind.cmd+d = workspace dev
4250
4351 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
@@ -35,9 +35,17 @@ layout.presets = 1/3, 1/2, 2/3, 1
35 # when empty. hidden = on keeps one out of the numbers and of up/down: only its35 # when empty. hidden = on keeps one out of the numbers and of up/down: only its
36 # bind shows it, and pressing the bind again returns to the previous workspace.36 # bind shows it, and pressing the bind again returns to the previous workspace.
37 # max-columns and overflow override the layout.* values.37 # max-columns and overflow override the layout.* values.
38+# workspace.web.max-columns = 3
39+# workspace.web.overflow = fixed
40+#
41+# Slots: column.N = <width>, <command>. nefertty starts each command in the
42+# background and puts its first window in column N, without taking focus.
43+# A closed slot stays empty until the workspace is shown again. Apps that
44+# reuse a running instance need a new-instance flag. Widths need
45+# overflow = scroll.
38 # workspace.dev.hidden = on46 # workspace.dev.hidden = on
39-# workspace.dev.max-columns = 347+# workspace.dev.column.1 = 67%, code --new-window
40-# workspace.dev.overflow = fixed48+# workspace.dev.column.2 = 33%, foot
41 # bind.cmd+d = workspace dev49 # bind.cmd+d = workspace dev
42 50
43 # Displays (drm backend): WxH, WxH@Hz, preferred or off.51 # Displays (drm backend): WxH, WxH@Hz, preferred or off.
M internal/adapters/config/config.go +35 -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
@@ -455,6 +456,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
455456 }
456457 }
457458 for _, w := range c.Workspaces {
459+ overflow := w.Overflow
460+ if overflow == "" {
461+ overflow = c.Layout.Overflow
462+ }
463+ if overflow == "fixed" && len(w.Slots) > 0 {
464+ line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
465+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
466+ }
458467 if w.Hidden && !bound[w.Name] {
459468 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460469 }
@@ -482,7 +491,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
482491 }
483492 w.Overflow = v
484493 default:
485- return fmt.Errorf("unknown key (hidden, max-columns, overflow)")
494+ n, ok := strings.CutPrefix(field, "column.")
495+ if !ok {
496+ return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
497+ }
498+ index, err := strconv.Atoi(n)
499+ if err != nil || index < 1 || index > 16 {
500+ return fmt.Errorf("column number must be between 1 and 16")
501+ }
502+ size, cmd, _ := strings.Cut(v, ",")
503+ size = strings.TrimSpace(size)
504+ argv := strings.Fields(cmd)
505+ if !width(size) || len(argv) == 0 {
506+ return fmt.Errorf("must be <width>, <command> like 67%%, foot")
507+ }
508+ slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
509+ i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
510+ if i < len(w.Slots) && w.Slots[i].Index == index {
511+ w.Slots[i] = slot
512+ } else {
513+ w.Slots = slices.Insert(w.Slots, i, slot)
514+ }
486515 }
487516 return nil
488517 }
@@ -680,6 +709,10 @@ func width(s string) bool {
680709 if s == "1" {
681710 return true
682711 }
712+ if n, ok := strings.CutSuffix(s, "%"); ok {
713+ v, e := strconv.Atoi(n)
714+ return e == nil && v > 0 && v <= 100
715+ }
683716 if strings.HasSuffix(s, "px") {
684717 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
685718 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
@@ -455,6 +456,14 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
455 }456 }
456 }457 }
457 for _, w := range c.Workspaces {458 for _, w := range c.Workspaces {
459+ overflow := w.Overflow
460+ if overflow == "" {
461+ overflow = c.Layout.Overflow
462+ }
463+ if overflow == "fixed" && len(w.Slots) > 0 {
464+ line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
465+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
466+ }
458 if w.Hidden && !bound[w.Name] {467 if w.Hidden && !bound[w.Name] {
459 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})468 warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)})
460 }469 }
@@ -482,7 +491,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
482 }491 }
483 w.Overflow = v492 w.Overflow = v
484 default:493 default:
485- return fmt.Errorf("unknown key (hidden, max-columns, overflow)")494+ n, ok := strings.CutPrefix(field, "column.")
495+ if !ok {
496+ return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)")
497+ }
498+ index, err := strconv.Atoi(n)
499+ if err != nil || index < 1 || index > 16 {
500+ return fmt.Errorf("column number must be between 1 and 16")
501+ }
502+ size, cmd, _ := strings.Cut(v, ",")
503+ size = strings.TrimSpace(size)
504+ argv := strings.Fields(cmd)
505+ if !width(size) || len(argv) == 0 {
506+ return fmt.Errorf("must be <width>, <command> like 67%%, foot")
507+ }
508+ slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
509+ i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
510+ if i < len(w.Slots) && w.Slots[i].Index == index {
511+ w.Slots[i] = slot
512+ } else {
513+ w.Slots = slices.Insert(w.Slots, i, slot)
514+ }
486 }515 }
487 return nil516 return nil
488 }517 }
@@ -680,6 +709,10 @@ func width(s string) bool {
680 if s == "1" {709 if s == "1" {
681 return true710 return true
682 }711 }
712+ if n, ok := strings.CutSuffix(s, "%"); ok {
713+ v, e := strconv.Atoi(n)
714+ return e == nil && v > 0 && v <= 100
715+ }
683 if strings.HasSuffix(s, "px") {716 if strings.HasSuffix(s, "px") {
684 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))717 n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
685 return e == nil && n > 0718 return e == nil && n > 0
M internal/adapters/config/config_test.go +35 -0
@@ -267,3 +267,38 @@ 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+}
296+
297+func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
298+ _, w := parseString(t, `workspace.dev.overflow = fixed
299+workspace.dev.column.1 = 67%, foot
300+`)
301+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {
302+ t.Fatal(w)
303+ }
304+}
@@ -267,3 +267,38 @@ 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+}
296+
297+func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
298+ _, w := parseString(t, `workspace.dev.overflow = fixed
299+workspace.dev.column.1 = 67%, foot
300+`)
301+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "ignored") {
302+ t.Fatal(w)
303+ }
304+}
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 +5 -1
@@ -54,6 +54,8 @@ type Channels struct {
5454 }
5555 type Server struct {
5656 display *server.Display
57+ env procEnv
58+ slotsPending bool // core waits for a slot window
5759 name string
5860 cleanup func()
5961 log zerowrap.Logger
@@ -125,7 +127,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125127 d.Close()
126128 return nil, err
127129 }
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}
130+ 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}
129131 s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}
130132 if opts.Keymap != "" {
131133 fd, size, e := keymapFile(opts.Keymap)
@@ -420,6 +422,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
420422 w.sendConfigure()
421423 case ports.SetOutputScale:
422424 s.setOutputScale(c)
425+ case ports.SlotsPending:
426+ s.slotsPending = c.Pending
423427 case ports.CloseWindow:
424428 w := s.windows[c.ID]
425429 if w == nil || !w.toplevel.Resource.Alive() {
@@ -54,6 +54,8 @@ type Channels struct {
54 }54 }
55 type Server struct {55 type Server struct {
56 display *server.Display56 display *server.Display
57+ env procEnv
58+ slotsPending bool // core waits for a slot window
57 name string59 name string
58 cleanup func()60 cleanup func()
59 log zerowrap.Logger61 log zerowrap.Logger
@@ -125,7 +127,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
125 d.Close()127 d.Close()
126 return nil, err128 return nil, err
127 }129 }
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}130+ 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{}}131 s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}
130 if opts.Keymap != "" {132 if opts.Keymap != "" {
131 fd, size, e := keymapFile(opts.Keymap)133 fd, size, e := keymapFile(opts.Keymap)
@@ -420,6 +422,8 @@ func (s *Server) apply(cmd ports.ClientCommand) {
420 w.sendConfigure()422 w.sendConfigure()
421 case ports.SetOutputScale:423 case ports.SetOutputScale:
422 s.setOutputScale(c)424 s.setOutputScale(c)
425+ case ports.SlotsPending:
426+ s.slotsPending = c.Pending
423 case ports.CloseWindow:427 case ports.CloseWindow:
424 w := s.windows[c.ID]428 w := s.windows[c.ID]
425 if w == nil || !w.toplevel.Resource.Alive() {429 if w == nil || !w.toplevel.Resource.Alive() {
A internal/adapters/wayland/slot.go +48 -0
@@ -0,0 +1,48 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// procEnv reads one environment variable of a running process.
13+type procEnv interface {
14+ Lookup(pid int, key string) (string, bool)
15+}
16+
17+// linuxProcEnv reads /proc/<pid>/environ: the environment the process started
18+// with. A slot command that forks its window process passes it down, since
19+// children inherit the environment.
20+type linuxProcEnv struct{}
21+
22+func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
23+ if pid <= 0 {
24+ return "", false
25+ }
26+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
27+ if err != nil {
28+ return "", false
29+ }
30+ prefix := []byte(key + "=")
31+ for entry := range bytes.SplitSeq(data, []byte{0}) {
32+ if v, ok := bytes.CutPrefix(entry, prefix); ok {
33+ return string(v), true
34+ }
35+ }
36+ return "", false
37+}
38+
39+// slotToken returns the SlotEnv value of the client process, if any. It
40+// reads /proc only while core waits for a slot window. There is no per-client
41+// cache: libwayland may reuse a client address after a disconnect.
42+func (s *Server) slotToken(c server.Client) string {
43+ if !s.slotsPending {
44+ return ""
45+ }
46+ v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
47+ return v
48+}
@@ -0,0 +1,48 @@
1+package wayland
2+
3+import (
4+ "bytes"
5+ "os"
6+ "strconv"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+ "github.com/bnema/purego-libwayland/server"
10+)
11+
12+// procEnv reads one environment variable of a running process.
13+type procEnv interface {
14+ Lookup(pid int, key string) (string, bool)
15+}
16+
17+// linuxProcEnv reads /proc/<pid>/environ: the environment the process started
18+// with. A slot command that forks its window process passes it down, since
19+// children inherit the environment.
20+type linuxProcEnv struct{}
21+
22+func (linuxProcEnv) Lookup(pid int, key string) (string, bool) {
23+ if pid <= 0 {
24+ return "", false
25+ }
26+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
27+ if err != nil {
28+ return "", false
29+ }
30+ prefix := []byte(key + "=")
31+ for entry := range bytes.SplitSeq(data, []byte{0}) {
32+ if v, ok := bytes.CutPrefix(entry, prefix); ok {
33+ return string(v), true
34+ }
35+ }
36+ return "", false
37+}
38+
39+// slotToken returns the SlotEnv value of the client process, if any. It
40+// reads /proc only while core waits for a slot window. There is no per-client
41+// cache: libwayland may reuse a client address after a disconnect.
42+func (s *Server) slotToken(c server.Client) string {
43+ if !s.slotsPending {
44+ return ""
45+ }
46+ v, _ := s.env.Lookup(c.PID(), ports.SlotEnv)
47+ return v
48+}
A internal/adapters/wayland/slot_test.go +39 -0
@@ -0,0 +1,39 @@
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+ s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
16+ mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17+ // No slot pending: /proc is not read (the mock would fail on a call).
18+ if w := mapWindow(); w.Slot != "" {
19+ t.Fatalf("slot %q", w.Slot)
20+ }
21+ commands <- ports.SlotsPending{Pending: true}
22+ if w := mapWindow(); w.Slot != "7" {
23+ t.Fatalf("slot %q", w.Slot)
24+ }
25+}
26+
27+func TestLinuxProcEnv(t *testing.T) {
28+ t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env
29+ if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok {
30+ t.Fatal("environ reflects later changes")
31+ }
32+ path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH")
33+ if want, _ := os.LookupEnv("PATH"); ok != (want != "") {
34+ t.Fatalf("PATH %q %v", path, ok)
35+ }
36+ if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok {
37+ t.Fatal("pid 0")
38+ }
39+}
@@ -0,0 +1,39 @@
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+ s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
16+ mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17+ // No slot pending: /proc is not read (the mock would fail on a call).
18+ if w := mapWindow(); w.Slot != "" {
19+ t.Fatalf("slot %q", w.Slot)
20+ }
21+ commands <- ports.SlotsPending{Pending: true}
22+ if w := mapWindow(); w.Slot != "7" {
23+ t.Fatalf("slot %q", w.Slot)
24+ }
25+}
26+
27+func TestLinuxProcEnv(t *testing.T) {
28+ t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env
29+ if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok {
30+ t.Fatal("environ reflects later changes")
31+ }
32+ path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH")
33+ if want, _ := os.LookupEnv("PATH"); ok != (want != "") {
34+ t.Fatalf("PATH %q %v", path, ok)
35+ }
36+ if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok {
37+ t.Fatal("pid 0")
38+ }
39+}
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())
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())
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 +72 -2
@@ -50,6 +50,10 @@ type Core struct {
5050 physW, physH int
5151 scale, cfgScale float64
5252 sentScale ports.SetOutputScale
53+ slots map[slotKey]*slotState
54+ // toSpawn holds slots to start; Run sends them (apply has no context).
55+ toSpawn []slotKey
56+ sentPending bool
5357 }
5458
5559 func keyName(s string) string {
@@ -169,9 +173,14 @@ func (c *Core) apply(cfg ports.Config) error {
169173 seen[ws.Name] = true
170174 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171175 }
176+ specs, err := parseSlots(cfg.Workspaces)
177+ if err != nil {
178+ return err
179+ }
172180 c.cfg = cfg
173181 c.binds = binds
174182 c.applyConfigScale()
183+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
175184 c.ws.SetNamed(named)
176185 c.ws.SetOverflow(defOverflow)
177186 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
@@ -179,11 +188,54 @@ func (c *Core) apply(cfg ports.Config) error {
179188 c.ws.SetGaps(cfg.Layout.Gaps)
180189 return nil
181190 }
191+
192+// publishPending tells wayland at once when slots start waiting, before
193+// their windows can map.
194+func (c *Core) publishPending(ctx context.Context) error {
195+ if p := c.anyPending(); p != c.sentPending {
196+ if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
197+ return err
198+ }
199+ c.sentPending = p
200+ }
201+ return nil
202+}
203+
204+// spawnSlots starts the slots queued by config. With shown set (the user
205+// just switched to another workspace), it also refills the empty slots of
206+// the workspace now on screen.
207+func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
208+ keys := c.toSpawn
209+ c.toSpawn = nil
210+ if shown {
211+ keys = append(keys, c.refill()...)
212+ }
213+ started := map[slotKey]bool{}
214+ for _, key := range keys {
215+ st, ok := c.slots[key]
216+ // A key can be queued twice (new slot on the workspace on screen).
217+ if !ok || started[key] || st.window != 0 {
218+ continue
219+ }
220+ started[key] = true
221+ req := c.spawnSlot(key)
222+ // Wayland must read slot tokens before this window can map.
223+ if err := c.publishPending(ctx); err != nil {
224+ return err
225+ }
226+ select {
227+ case <-ctx.Done():
228+ return ctx.Err()
229+ case c.ch.Spawn <- req:
230+ }
231+ }
232+ return nil
233+}
182234 func New(cfg ports.Config, ch Channels) (*Core, error) {
183235 if cap(ch.Scenes) != 1 {
184236 return nil, fmt.Errorf("scenes must have capacity 1")
185237 }
186- c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
238+ c := &Core{scale: 1, ws: NewMonitor(), slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
187239 if err := c.apply(cfg); err != nil {
188240 return nil, err
189241 }
@@ -271,6 +323,9 @@ func (c *Core) publish(ctx context.Context) error {
271323 }
272324 c.sentScale = v
273325 }
326+ if err := c.publishPending(ctx); err != nil {
327+ return err
328+ }
274329 c.seq++
275330 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
276331 alive := map[WindowID]bool{}
@@ -334,6 +389,9 @@ func (c *Core) publish(ctx context.Context) error {
334389 return nil
335390 }
336391 func (c *Core) Run(ctx context.Context) error {
392+ if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
393+ return nil
394+ }
337395 for {
338396 select {
339397 case <-ctx.Done():
@@ -351,9 +409,12 @@ func (c *Core) Run(ctx context.Context) error {
351409 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
352410 c.ws.SetUsable(usable)
353411 case ports.WindowMapped:
354- c.ws.AddWindow(v.ID)
412+ if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {
413+ c.ws.AddWindow(v.ID)
414+ }
355415 case ports.WindowUnmapped:
356416 c.ws.RemoveWindow(v.ID)
417+ c.releaseSlots()
357418 if c.pointer == v.ID {
358419 c.pointer = 0
359420 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -400,6 +461,9 @@ func (c *Core) Run(ctx context.Context) error {
400461 }
401462 continue
402463 }
464+ if c.spawnSlots(ctx, false) != nil {
465+ return nil
466+ }
403467 if err := c.publish(ctx); err != nil {
404468 return nil
405469 }
@@ -508,6 +572,7 @@ func (c *Core) Run(ctx context.Context) error {
508572 }
509573 continue
510574 }
575+ before := c.ws.Current()
511576 effect := c.ws.Apply(action)
512577 if effect.Quit {
513578 return ErrQuit
@@ -523,6 +588,11 @@ func (c *Core) Run(ctx context.Context) error {
523588 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
524589 }
525590 }
591+ // Showing a declared workspace refills its empty slots.
592+ c.releaseSlots()
593+ if c.spawnSlots(ctx, c.ws.Current() != before) != nil {
594+ return nil
595+ }
526596 if effect.Close != 0 {
527597 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
528598 return nil
@@ -50,6 +50,10 @@ 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 map[slotKey]*slotState
54+ // toSpawn holds slots to start; Run sends them (apply has no context).
55+ toSpawn []slotKey
56+ sentPending bool
53 }57 }
54 58
55 func keyName(s string) string {59 func keyName(s string) string {
@@ -169,9 +173,14 @@ func (c *Core) apply(cfg ports.Config) error {
169 seen[ws.Name] = true173 seen[ws.Name] = true
170 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})174 named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
171 }175 }
176+ specs, err := parseSlots(cfg.Workspaces)
177+ if err != nil {
178+ return err
179+ }
172 c.cfg = cfg180 c.cfg = cfg
173 c.binds = binds181 c.binds = binds
174 c.applyConfigScale()182 c.applyConfigScale()
183+ c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
175 c.ws.SetNamed(named)184 c.ws.SetNamed(named)
176 c.ws.SetOverflow(defOverflow)185 c.ws.SetOverflow(defOverflow)
177 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)186 c.ws.SetMaxColumns(cfg.Layout.MaxColumns)
@@ -179,11 +188,54 @@ func (c *Core) apply(cfg ports.Config) error {
179 c.ws.SetGaps(cfg.Layout.Gaps)188 c.ws.SetGaps(cfg.Layout.Gaps)
180 return nil189 return nil
181 }190 }
191+
192+// publishPending tells wayland at once when slots start waiting, before
193+// their windows can map.
194+func (c *Core) publishPending(ctx context.Context) error {
195+ if p := c.anyPending(); p != c.sentPending {
196+ if err := c.command(ctx, ports.SlotsPending{Pending: p}); err != nil {
197+ return err
198+ }
199+ c.sentPending = p
200+ }
201+ return nil
202+}
203+
204+// spawnSlots starts the slots queued by config. With shown set (the user
205+// just switched to another workspace), it also refills the empty slots of
206+// the workspace now on screen.
207+func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
208+ keys := c.toSpawn
209+ c.toSpawn = nil
210+ if shown {
211+ keys = append(keys, c.refill()...)
212+ }
213+ started := map[slotKey]bool{}
214+ for _, key := range keys {
215+ st, ok := c.slots[key]
216+ // A key can be queued twice (new slot on the workspace on screen).
217+ if !ok || started[key] || st.window != 0 {
218+ continue
219+ }
220+ started[key] = true
221+ req := c.spawnSlot(key)
222+ // Wayland must read slot tokens before this window can map.
223+ if err := c.publishPending(ctx); err != nil {
224+ return err
225+ }
226+ select {
227+ case <-ctx.Done():
228+ return ctx.Err()
229+ case c.ch.Spawn <- req:
230+ }
231+ }
232+ return nil
233+}
182 func New(cfg ports.Config, ch Channels) (*Core, error) {234 func New(cfg ports.Config, ch Channels) (*Core, error) {
183 if cap(ch.Scenes) != 1 {235 if cap(ch.Scenes) != 1 {
184 return nil, fmt.Errorf("scenes must have capacity 1")236 return nil, fmt.Errorf("scenes must have capacity 1")
185 }237 }
186- c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}238+ c := &Core{scale: 1, ws: NewMonitor(), slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
187 if err := c.apply(cfg); err != nil {239 if err := c.apply(cfg); err != nil {
188 return nil, err240 return nil, err
189 }241 }
@@ -271,6 +323,9 @@ func (c *Core) publish(ctx context.Context) error {
271 }323 }
272 c.sentScale = v324 c.sentScale = v
273 }325 }
326+ if err := c.publishPending(ctx); err != nil {
327+ return err
328+ }
274 c.seq++329 c.seq++
275 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}330 scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
276 alive := map[WindowID]bool{}331 alive := map[WindowID]bool{}
@@ -334,6 +389,9 @@ func (c *Core) publish(ctx context.Context) error {
334 return nil389 return nil
335 }390 }
336 func (c *Core) Run(ctx context.Context) error {391 func (c *Core) Run(ctx context.Context) error {
392+ if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
393+ return nil
394+ }
337 for {395 for {
338 select {396 select {
339 case <-ctx.Done():397 case <-ctx.Done():
@@ -351,9 +409,12 @@ func (c *Core) Run(ctx context.Context) error {
351 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)409 c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
352 c.ws.SetUsable(usable)410 c.ws.SetUsable(usable)
353 case ports.WindowMapped:411 case ports.WindowMapped:
354- c.ws.AddWindow(v.ID)412+ if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {
413+ c.ws.AddWindow(v.ID)
414+ }
355 case ports.WindowUnmapped:415 case ports.WindowUnmapped:
356 c.ws.RemoveWindow(v.ID)416 c.ws.RemoveWindow(v.ID)
417+ c.releaseSlots()
357 if c.pointer == v.ID {418 if c.pointer == v.ID {
358 c.pointer = 0419 c.pointer = 0
359 if err := c.command(ctx, ports.PointerFocus{}); err != nil {420 if err := c.command(ctx, ports.PointerFocus{}); err != nil {
@@ -400,6 +461,9 @@ func (c *Core) Run(ctx context.Context) error {
400 }461 }
401 continue462 continue
402 }463 }
464+ if c.spawnSlots(ctx, false) != nil {
465+ return nil
466+ }
403 if err := c.publish(ctx); err != nil {467 if err := c.publish(ctx); err != nil {
404 return nil468 return nil
405 }469 }
@@ -508,6 +572,7 @@ func (c *Core) Run(ctx context.Context) error {
508 }572 }
509 continue573 continue
510 }574 }
575+ before := c.ws.Current()
511 effect := c.ws.Apply(action)576 effect := c.ws.Apply(action)
512 if effect.Quit {577 if effect.Quit {
513 return ErrQuit578 return ErrQuit
@@ -523,6 +588,11 @@ func (c *Core) Run(ctx context.Context) error {
523 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:588 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
524 }589 }
525 }590 }
591+ // Showing a declared workspace refills its empty slots.
592+ c.releaseSlots()
593+ if c.spawnSlots(ctx, c.ws.Current() != before) != nil {
594+ return nil
595+ }
526 if effect.Close != 0 {596 if effect.Close != 0 {
527 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {597 if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil {
528 return nil598 return nil
M internal/core/layout.go +60 -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,57 @@ 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+ // Right after the last slot column with a lower number, else first.
175+ at := 0
176+ for i, c := range w.Columns {
177+ if c.Slot > 0 && c.Slot < slot {
178+ at = i + 1
179+ }
180+ }
181+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
182+ if len(w.Columns) > 1 && at <= w.Focus {
183+ w.Focus++
184+ }
185+ w.scroll()
186+}
187+
188+// setSlotWidth updates the width of slot column n, if present.
189+func (w *Workspace) setSlotWidth(n int, width Width) {
190+ for i := range w.Columns {
191+ if w.Columns[i].Slot == n {
192+ w.Columns[i].Width = width
193+ }
194+ }
195+ w.scroll()
196+}
197+
198+// inSlot reports whether window id is in slot column n.
199+func (w *Workspace) inSlot(id WindowID, n int) bool {
200+ for _, c := range w.Columns {
201+ if c.Slot == n && slices.Contains(c.Windows, id) {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
208+// unslot turns slot column n into a normal column.
209+func (w *Workspace) unslot(n int) {
210+ for i := range w.Columns {
211+ if w.Columns[i].Slot == n {
212+ w.Columns[i].Slot = 0
213+ }
214+ }
215+}
216+
157217 func (w *Workspace) RemoveWindow(id WindowID) {
158218 for i := range w.Columns {
159219 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,57 @@ 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+ // Right after the last slot column with a lower number, else first.
175+ at := 0
176+ for i, c := range w.Columns {
177+ if c.Slot > 0 && c.Slot < slot {
178+ at = i + 1
179+ }
180+ }
181+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
182+ if len(w.Columns) > 1 && at <= w.Focus {
183+ w.Focus++
184+ }
185+ w.scroll()
186+}
187+
188+// setSlotWidth updates the width of slot column n, if present.
189+func (w *Workspace) setSlotWidth(n int, width Width) {
190+ for i := range w.Columns {
191+ if w.Columns[i].Slot == n {
192+ w.Columns[i].Width = width
193+ }
194+ }
195+ w.scroll()
196+}
197+
198+// inSlot reports whether window id is in slot column n.
199+func (w *Workspace) inSlot(id WindowID, n int) bool {
200+ for _, c := range w.Columns {
201+ if c.Slot == n && slices.Contains(c.Windows, id) {
202+ return true
203+ }
204+ }
205+ return false
206+}
207+
208+// unslot turns slot column n into a normal column.
209+func (w *Workspace) unslot(n int) {
210+ for i := range w.Columns {
211+ if w.Columns[i].Slot == n {
212+ w.Columns[i].Slot = 0
213+ }
214+ }
215+}
216+
157 func (w *Workspace) RemoveWindow(id WindowID) {217 func (w *Workspace) RemoveWindow(id WindowID) {
158 for i := range w.Columns {218 for i := range w.Columns {
159 for j, v := range w.Columns[i].Windows {219 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 +182 -0
@@ -0,0 +1,182 @@
1+package core
2+
3+import (
4+ "crypto/rand"
5+ "fmt"
6+ "slices"
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 random token,
14+// and the first window of a client carrying that token fills the slot.
15+//
16+// Only the token of the pending spawn is accepted: once the slot is filled,
17+// or the command changes, other windows with that token (children of the slot
18+// app inherit its environment) open as normal windows.
19+//
20+// A slot is spawned at startup, and again when the user shows its workspace
21+// while it is empty. Nothing is relaunched on its own.
22+
23+type slotKey struct {
24+ workspace string
25+ index int
26+}
27+
28+type slotState struct {
29+ argv []string
30+ width Width
31+ token string // SlotEnv value of the pending spawn; "" when none
32+ window WindowID // 0 while empty
33+ // stale is set when the workspace is shown while the spawn is pending.
34+ // Shown again still pending, the command is deemed failed and respawned;
35+ // a slow app therefore gets one show to map its window.
36+ stale bool
37+}
38+
39+func (s *slotState) pending() bool { return s.token != "" }
40+
41+// slotSpec is one parsed slot of the config.
42+type slotSpec struct {
43+ key slotKey
44+ argv []string
45+ width Width
46+}
47+
48+func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
49+ var specs []slotSpec
50+ for _, w := range ws {
51+ for _, s := range w.Slots {
52+ width, err := ParseWidth(s.Width)
53+ if err != nil {
54+ return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
55+ }
56+ if len(s.Argv) == 0 || s.Index < 1 {
57+ return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
58+ }
59+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
60+ }
61+ }
62+ return specs, nil
63+}
64+
65+// updateSlots applies the config and returns the slots to spawn: new ones,
66+// and empty ones whose command changed. A slot dropped from config keeps its
67+// window as a normal column. Call before Monitor.SetNamed, so a renamed or
68+// dropped workspace can still be found.
69+func (c *Core) updateSlots(specs []slotSpec) []slotKey {
70+ want := map[slotKey]slotSpec{}
71+ for _, s := range specs {
72+ want[s.key] = s
73+ }
74+ for key := range c.slots {
75+ if _, ok := want[key]; ok {
76+ continue
77+ }
78+ if w := c.ws.byName(key.workspace); w != nil {
79+ w.unslot(key.index)
80+ }
81+ delete(c.slots, key)
82+ }
83+ var spawn []slotKey
84+ for _, s := range specs {
85+ st, ok := c.slots[s.key]
86+ if !ok {
87+ c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
88+ spawn = append(spawn, s.key)
89+ continue
90+ }
91+ if st.width != s.width {
92+ st.width = s.width
93+ if w := c.ws.byName(s.key.workspace); w != nil {
94+ w.setSlotWidth(s.key.index, s.width)
95+ }
96+ }
97+ if !slices.Equal(st.argv, s.argv) {
98+ st.argv = s.argv
99+ if st.window == 0 {
100+ // A pending spawn ran the old command: its window opens as
101+ // a normal one; the new command replaces it.
102+ st.token = ""
103+ spawn = append(spawn, s.key)
104+ }
105+ }
106+ }
107+ return spawn
108+}
109+
110+// spawnSlot starts the slot's command with a fresh random token.
111+func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
112+ st := c.slots[key]
113+ st.token, st.stale = rand.Text(), false
114+ return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
115+}
116+
117+// placeSlotWindow puts a mapped window carrying the pending token of a slot
118+// into that slot, without focus. It returns false for any other window, which
119+// then follows the normal rules.
120+func (c *Core) placeSlotWindow(id WindowID, token string) bool {
121+ for key, st := range c.slots {
122+ if !st.pending() || st.token != token || st.window != 0 {
123+ continue
124+ }
125+ w := c.ws.byName(key.workspace)
126+ if w == nil {
127+ return false
128+ }
129+ st.window, st.token = id, ""
130+ w.AddSlotWindow(id, key.index, st.width)
131+ c.ws.normalize()
132+ return true
133+ }
134+ return false
135+}
136+
137+// releaseSlots empties slots whose window is gone or no longer in its slot
138+// column (closed, or moved to another workspace by the user).
139+func (c *Core) releaseSlots() {
140+ for key, st := range c.slots {
141+ if st.window == 0 {
142+ continue
143+ }
144+ w := c.ws.byName(key.workspace)
145+ if w == nil || !w.inSlot(st.window, key.index) {
146+ st.window = 0
147+ }
148+ }
149+}
150+
151+// refill returns the empty slots of the workspace on screen to spawn. A slot
152+// still pending for the second show in a row is respawned: its command
153+// failed, exited or handed off to another instance without a window.
154+func (c *Core) refill() []slotKey {
155+ name := c.ws.Current().Name
156+ if name == "" {
157+ return nil
158+ }
159+ var keys []slotKey
160+ for key, st := range c.slots {
161+ switch {
162+ case key.workspace != name || st.window != 0:
163+ case st.pending() && !st.stale:
164+ st.stale = true
165+ default:
166+ st.token = ""
167+ keys = append(keys, key)
168+ }
169+ }
170+ slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
171+ return keys
172+}
173+
174+// anyPending reports whether a slot waits for its window.
175+func (c *Core) anyPending() bool {
176+ for _, st := range c.slots {
177+ if st.pending() {
178+ return true
179+ }
180+ }
181+ return false
182+}
@@ -0,0 +1,182 @@
1+package core
2+
3+import (
4+ "crypto/rand"
5+ "fmt"
6+ "slices"
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 random token,
14+// and the first window of a client carrying that token fills the slot.
15+//
16+// Only the token of the pending spawn is accepted: once the slot is filled,
17+// or the command changes, other windows with that token (children of the slot
18+// app inherit its environment) open as normal windows.
19+//
20+// A slot is spawned at startup, and again when the user shows its workspace
21+// while it is empty. Nothing is relaunched on its own.
22+
23+type slotKey struct {
24+ workspace string
25+ index int
26+}
27+
28+type slotState struct {
29+ argv []string
30+ width Width
31+ token string // SlotEnv value of the pending spawn; "" when none
32+ window WindowID // 0 while empty
33+ // stale is set when the workspace is shown while the spawn is pending.
34+ // Shown again still pending, the command is deemed failed and respawned;
35+ // a slow app therefore gets one show to map its window.
36+ stale bool
37+}
38+
39+func (s *slotState) pending() bool { return s.token != "" }
40+
41+// slotSpec is one parsed slot of the config.
42+type slotSpec struct {
43+ key slotKey
44+ argv []string
45+ width Width
46+}
47+
48+func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
49+ var specs []slotSpec
50+ for _, w := range ws {
51+ for _, s := range w.Slots {
52+ width, err := ParseWidth(s.Width)
53+ if err != nil {
54+ return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
55+ }
56+ if len(s.Argv) == 0 || s.Index < 1 {
57+ return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
58+ }
59+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
60+ }
61+ }
62+ return specs, nil
63+}
64+
65+// updateSlots applies the config and returns the slots to spawn: new ones,
66+// and empty ones whose command changed. A slot dropped from config keeps its
67+// window as a normal column. Call before Monitor.SetNamed, so a renamed or
68+// dropped workspace can still be found.
69+func (c *Core) updateSlots(specs []slotSpec) []slotKey {
70+ want := map[slotKey]slotSpec{}
71+ for _, s := range specs {
72+ want[s.key] = s
73+ }
74+ for key := range c.slots {
75+ if _, ok := want[key]; ok {
76+ continue
77+ }
78+ if w := c.ws.byName(key.workspace); w != nil {
79+ w.unslot(key.index)
80+ }
81+ delete(c.slots, key)
82+ }
83+ var spawn []slotKey
84+ for _, s := range specs {
85+ st, ok := c.slots[s.key]
86+ if !ok {
87+ c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
88+ spawn = append(spawn, s.key)
89+ continue
90+ }
91+ if st.width != s.width {
92+ st.width = s.width
93+ if w := c.ws.byName(s.key.workspace); w != nil {
94+ w.setSlotWidth(s.key.index, s.width)
95+ }
96+ }
97+ if !slices.Equal(st.argv, s.argv) {
98+ st.argv = s.argv
99+ if st.window == 0 {
100+ // A pending spawn ran the old command: its window opens as
101+ // a normal one; the new command replaces it.
102+ st.token = ""
103+ spawn = append(spawn, s.key)
104+ }
105+ }
106+ }
107+ return spawn
108+}
109+
110+// spawnSlot starts the slot's command with a fresh random token.
111+func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
112+ st := c.slots[key]
113+ st.token, st.stale = rand.Text(), false
114+ return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}}
115+}
116+
117+// placeSlotWindow puts a mapped window carrying the pending token of a slot
118+// into that slot, without focus. It returns false for any other window, which
119+// then follows the normal rules.
120+func (c *Core) placeSlotWindow(id WindowID, token string) bool {
121+ for key, st := range c.slots {
122+ if !st.pending() || st.token != token || st.window != 0 {
123+ continue
124+ }
125+ w := c.ws.byName(key.workspace)
126+ if w == nil {
127+ return false
128+ }
129+ st.window, st.token = id, ""
130+ w.AddSlotWindow(id, key.index, st.width)
131+ c.ws.normalize()
132+ return true
133+ }
134+ return false
135+}
136+
137+// releaseSlots empties slots whose window is gone or no longer in its slot
138+// column (closed, or moved to another workspace by the user).
139+func (c *Core) releaseSlots() {
140+ for key, st := range c.slots {
141+ if st.window == 0 {
142+ continue
143+ }
144+ w := c.ws.byName(key.workspace)
145+ if w == nil || !w.inSlot(st.window, key.index) {
146+ st.window = 0
147+ }
148+ }
149+}
150+
151+// refill returns the empty slots of the workspace on screen to spawn. A slot
152+// still pending for the second show in a row is respawned: its command
153+// failed, exited or handed off to another instance without a window.
154+func (c *Core) refill() []slotKey {
155+ name := c.ws.Current().Name
156+ if name == "" {
157+ return nil
158+ }
159+ var keys []slotKey
160+ for key, st := range c.slots {
161+ switch {
162+ case key.workspace != name || st.window != 0:
163+ case st.pending() && !st.stale:
164+ st.stale = true
165+ default:
166+ st.token = ""
167+ keys = append(keys, key)
168+ }
169+ }
170+ slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
171+ return keys
172+}
173+
174+// anyPending reports whether a slot waits for its window.
175+func (c *Core) anyPending() bool {
176+ for _, st := range c.slots {
177+ if st.pending() {
178+ return true
179+ }
180+ }
181+ return false
182+}
A internal/core/slots_test.go +349 -0
@@ -0,0 +1,349 @@
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, numbered ...bool) *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: len(numbered) == 0, 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+// noSpawn checks that nothing was spawned. Core sends spawns before it
74+// publishes the scene of the same event, so callers wait for that scene first.
75+func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
76+ t.Helper()
77+ select {
78+ case v := <-ch:
79+ t.Fatalf("unexpected spawn %v", v.Argv)
80+ default:
81+ }
82+}
83+
84+// press sends Alt+key and waits for the scene it publishes.
85+func (r *slotRig) press(t *testing.T, key string) ports.Scene {
86+ t.Helper()
87+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
88+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
89+ return receive(t, r.scenes)
90+}
91+
92+// sync waits for core to handle everything sent so far.
93+func (r *slotRig) sync(t *testing.T) ports.Scene {
94+ t.Helper()
95+ return r.press(t, "Left") // focus-column-left publishes a scene
96+}
97+
98+// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
99+// has placed them: client and input events are separate channels.
100+func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
101+ t.Helper()
102+ code, foot = receive(t, r.spawn), receive(t, r.spawn)
103+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
104+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
105+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
106+ return code, foot
107+}
108+
109+// scene waits for a scene matching ok.
110+func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
111+ t.Helper()
112+ deadline := time.After(time.Second)
113+ for {
114+ select {
115+ case s := <-ch:
116+ if ok(s) {
117+ return s
118+ }
119+ case <-deadline:
120+ t.Fatal("no matching scene")
121+ }
122+ }
123+}
124+
125+func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
126+ m := map[ports.WindowID]ports.Rect{}
127+ for _, w := range s.Windows {
128+ if !w.Hidden {
129+ m[w.ID] = w.Rect
130+ }
131+ }
132+ return m
133+}
134+
135+func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
136+ r := startSlots(t)
137+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
138+ if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
139+ t.Fatal(code, foot)
140+ }
141+ // A normal window on workspace 1 keeps focus while slot windows map.
142+ r.client <- ports.WindowMapped{ID: 1}
143+ // foot maps first; code's window still goes left of it.
144+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
145+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
146+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
147+ for _, w := range s.Windows {
148+ if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
149+ t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
150+ }
151+ }
152+ s = r.press(t, "d")
153+ got := visible(s)
154+ if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
155+ t.Fatal(got)
156+ }
157+ noSpawn(t, r.spawn) // full slots are not respawned
158+}
159+
160+func TestSlotRefilledOnlyWhenShown(t *testing.T) {
161+ r := startSlots(t)
162+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
163+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
164+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
165+ r.client <- ports.WindowUnmapped{ID: 3} // foot exits
166+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
167+ noSpawn(t, r.spawn) // nothing relaunches on its own
168+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
169+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
170+ again := receive(t, r.spawn)
171+ if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
172+ t.Fatal(again)
173+ }
174+ r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
175+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
176+ if got := visible(s); got[4].X != 70 {
177+ t.Fatal(got)
178+ }
179+ // Leaving and showing dev again with full slots spawns nothing.
180+ r.press(t, "d")
181+ r.press(t, "d")
182+ noSpawn(t, r.spawn)
183+}
184+
185+func TestSlotExtraWindowsAreNormal(t *testing.T) {
186+ r := startSlots(t)
187+ code := receive(t, r.spawn)
188+ receive(t, r.spawn)
189+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
190+ // A second window of the same process: the slot is taken, normal rules.
191+ r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
192+ r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
193+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
194+ if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
195+ t.Fatalf("extra windows not on the workspace on screen: %v", got)
196+ }
197+}
198+
199+func TestSlotReload(t *testing.T) {
200+ r := startSlots(t)
201+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
202+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
203+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
204+ cfg := r.cfg
205+ ws := cfg.Workspaces[0]
206+ ws.Slots = []ports.SlotConfig{
207+ {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
208+ {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
209+ {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
210+ }
211+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
212+ r.reload <- ports.ConfigChanged{Config: cfg}
213+ var argv []string
214+ for range 2 {
215+ argv = append(argv, receive(t, r.spawn).Argv[0])
216+ }
217+ slices.Sort(argv)
218+ if !slices.Equal(argv, []string{"btop", "kitty"}) {
219+ t.Fatal(argv)
220+ }
221+ receive(t, r.scenes)
222+ noSpawn(t, r.spawn)
223+ // The old foot spawn no longer fills slot 2 (its command changed): it
224+ // opens as a normal window on workspace 1, on screen.
225+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
226+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
227+ if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
228+ t.Fatal(got)
229+ }
230+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
231+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
232+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
233+ if got := visible(s); got[2].W != 50 {
234+ t.Fatal(got)
235+ }
236+ noSpawn(t, r.spawn) // kitty and btop are still starting
237+ // Dropping the workspace's slots spawns nothing and keeps the window.
238+ ws.Slots = nil
239+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
240+ r.reload <- ports.ConfigChanged{Config: cfg}
241+ receive(t, r.scenes)
242+ noSpawn(t, r.spawn)
243+}
244+
245+func TestInvalidSlotWidthRejected(t *testing.T) {
246+ r := startSlots(t)
247+ receive(t, r.spawn)
248+ receive(t, r.spawn)
249+ cfg := r.cfg
250+ ws := cfg.Workspaces[0]
251+ ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
252+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
253+ r.reload <- ports.ConfigChanged{Config: cfg}
254+ if err := receive(t, r.errs); err == nil {
255+ t.Fatal("no error")
256+ }
257+ r.sync(t)
258+ noSpawn(t, r.spawn)
259+}
260+
261+// Reloading while the slot workspace is on screen spawns a new slot once.
262+func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
263+ r := startSlots(t)
264+ r.fill(t)
265+ r.press(t, "d")
266+ cfg := r.cfg
267+ ws := cfg.Workspaces[0]
268+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
269+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
270+ r.reload <- ports.ConfigChanged{Config: cfg}
271+ if v := receive(t, r.spawn); v.Argv[0] != "btop" {
272+ t.Fatal(v)
273+ }
274+ receive(t, r.scenes)
275+ noSpawn(t, r.spawn)
276+}
277+
278+// Binds on the workspace on screen do not refill its empty slots; only
279+// showing it again does.
280+func TestSlotNotRefilledByOtherBinds(t *testing.T) {
281+ r := startSlots(t)
282+ r.fill(t)
283+ r.press(t, "d")
284+ r.client <- ports.WindowUnmapped{ID: 3}
285+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
286+ r.sync(t)
287+ noSpawn(t, r.spawn)
288+ r.press(t, "d")
289+ r.press(t, "d")
290+ if v := receive(t, r.spawn); v.Argv[0] != "foot" {
291+ t.Fatal(v)
292+ }
293+}
294+
295+// After a slot window closes, another window with the old token (a child
296+// of the slot app) opens as a normal window, not in the empty slot.
297+func TestSlotOldTokenIsNormal(t *testing.T) {
298+ r := startSlots(t)
299+ _, foot := r.fill(t)
300+ r.client <- ports.WindowUnmapped{ID: 3}
301+ r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
302+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
303+ if got := visible(s); got[9] == (ports.Rect{}) {
304+ t.Fatalf("old-token window hidden in the slot: %v", got)
305+ }
306+}
307+
308+// A slot whose command never maps a window gets one show to do it; on the
309+// next show it is respawned and its old token no longer fills it.
310+func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
311+ r := startSlots(t)
312+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
313+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
314+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
315+ r.press(t, "d") // foot still starting: not respawned
316+ noSpawn(t, r.spawn)
317+ r.press(t, "d")
318+ r.press(t, "d")
319+ again := receive(t, r.spawn)
320+ if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
321+ t.Fatal(again)
322+ }
323+ r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
324+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
325+ if got := visible(s); got[7] == (ports.Rect{}) {
326+ t.Fatalf("stale-token window not placed as a normal one: %v", got)
327+ }
328+ r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
329+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
330+ if got := visible(s); got[8].W != 30 {
331+ t.Fatalf("respawned window not in its slot: %v", got)
332+ }
333+}
334+
335+// Moving a slot window to another workspace releases the slot; showing the
336+// workspace again refills it.
337+func TestSlotReleasedWhenWindowMoved(t *testing.T) {
338+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
339+ r.fill(t)
340+ r.press(t, "d") // on dev, focus on code (slot 1)
341+ r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
342+ receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1
343+ noSpawn(t, r.spawn)
344+ r.press(t, "d") // back to workspace 1
345+ r.press(t, "d") // dev again: slot 1 is empty
346+ if v := receive(t, r.spawn); v.Argv[0] != "code" {
347+ t.Fatal(v)
348+ }
349+}
@@ -0,0 +1,349 @@
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, numbered ...bool) *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: len(numbered) == 0, 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+// noSpawn checks that nothing was spawned. Core sends spawns before it
74+// publishes the scene of the same event, so callers wait for that scene first.
75+func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
76+ t.Helper()
77+ select {
78+ case v := <-ch:
79+ t.Fatalf("unexpected spawn %v", v.Argv)
80+ default:
81+ }
82+}
83+
84+// press sends Alt+key and waits for the scene it publishes.
85+func (r *slotRig) press(t *testing.T, key string) ports.Scene {
86+ t.Helper()
87+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
88+ r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
89+ return receive(t, r.scenes)
90+}
91+
92+// sync waits for core to handle everything sent so far.
93+func (r *slotRig) sync(t *testing.T) ports.Scene {
94+ t.Helper()
95+ return r.press(t, "Left") // focus-column-left publishes a scene
96+}
97+
98+// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
99+// has placed them: client and input events are separate channels.
100+func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
101+ t.Helper()
102+ code, foot = receive(t, r.spawn), receive(t, r.spawn)
103+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
104+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
105+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
106+ return code, foot
107+}
108+
109+// scene waits for a scene matching ok.
110+func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {
111+ t.Helper()
112+ deadline := time.After(time.Second)
113+ for {
114+ select {
115+ case s := <-ch:
116+ if ok(s) {
117+ return s
118+ }
119+ case <-deadline:
120+ t.Fatal("no matching scene")
121+ }
122+ }
123+}
124+
125+func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
126+ m := map[ports.WindowID]ports.Rect{}
127+ for _, w := range s.Windows {
128+ if !w.Hidden {
129+ m[w.ID] = w.Rect
130+ }
131+ }
132+ return m
133+}
134+
135+func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
136+ r := startSlots(t)
137+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
138+ if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
139+ t.Fatal(code, foot)
140+ }
141+ // A normal window on workspace 1 keeps focus while slot windows map.
142+ r.client <- ports.WindowMapped{ID: 1}
143+ // foot maps first; code's window still goes left of it.
144+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
145+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
146+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
147+ for _, w := range s.Windows {
148+ if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
149+ t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
150+ }
151+ }
152+ s = r.press(t, "d")
153+ got := visible(s)
154+ if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
155+ t.Fatal(got)
156+ }
157+ noSpawn(t, r.spawn) // full slots are not respawned
158+}
159+
160+func TestSlotRefilledOnlyWhenShown(t *testing.T) {
161+ r := startSlots(t)
162+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
163+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
164+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
165+ r.client <- ports.WindowUnmapped{ID: 3} // foot exits
166+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
167+ noSpawn(t, r.spawn) // nothing relaunches on its own
168+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
169+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
170+ again := receive(t, r.spawn)
171+ if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
172+ t.Fatal(again)
173+ }
174+ r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
175+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
176+ if got := visible(s); got[4].X != 70 {
177+ t.Fatal(got)
178+ }
179+ // Leaving and showing dev again with full slots spawns nothing.
180+ r.press(t, "d")
181+ r.press(t, "d")
182+ noSpawn(t, r.spawn)
183+}
184+
185+func TestSlotExtraWindowsAreNormal(t *testing.T) {
186+ r := startSlots(t)
187+ code := receive(t, r.spawn)
188+ receive(t, r.spawn)
189+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
190+ // A second window of the same process: the slot is taken, normal rules.
191+ r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
192+ r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
193+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
194+ if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
195+ t.Fatalf("extra windows not on the workspace on screen: %v", got)
196+ }
197+}
198+
199+func TestSlotReload(t *testing.T) {
200+ r := startSlots(t)
201+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
202+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
203+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
204+ cfg := r.cfg
205+ ws := cfg.Workspaces[0]
206+ ws.Slots = []ports.SlotConfig{
207+ {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
208+ {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
209+ {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
210+ }
211+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
212+ r.reload <- ports.ConfigChanged{Config: cfg}
213+ var argv []string
214+ for range 2 {
215+ argv = append(argv, receive(t, r.spawn).Argv[0])
216+ }
217+ slices.Sort(argv)
218+ if !slices.Equal(argv, []string{"btop", "kitty"}) {
219+ t.Fatal(argv)
220+ }
221+ receive(t, r.scenes)
222+ noSpawn(t, r.spawn)
223+ // The old foot spawn no longer fills slot 2 (its command changed): it
224+ // opens as a normal window on workspace 1, on screen.
225+ r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
226+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
227+ if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
228+ t.Fatal(got)
229+ }
230+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
231+ r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
232+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
233+ if got := visible(s); got[2].W != 50 {
234+ t.Fatal(got)
235+ }
236+ noSpawn(t, r.spawn) // kitty and btop are still starting
237+ // Dropping the workspace's slots spawns nothing and keeps the window.
238+ ws.Slots = nil
239+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
240+ r.reload <- ports.ConfigChanged{Config: cfg}
241+ receive(t, r.scenes)
242+ noSpawn(t, r.spawn)
243+}
244+
245+func TestInvalidSlotWidthRejected(t *testing.T) {
246+ r := startSlots(t)
247+ receive(t, r.spawn)
248+ receive(t, r.spawn)
249+ cfg := r.cfg
250+ ws := cfg.Workspaces[0]
251+ ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
252+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
253+ r.reload <- ports.ConfigChanged{Config: cfg}
254+ if err := receive(t, r.errs); err == nil {
255+ t.Fatal("no error")
256+ }
257+ r.sync(t)
258+ noSpawn(t, r.spawn)
259+}
260+
261+// Reloading while the slot workspace is on screen spawns a new slot once.
262+func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
263+ r := startSlots(t)
264+ r.fill(t)
265+ r.press(t, "d")
266+ cfg := r.cfg
267+ ws := cfg.Workspaces[0]
268+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
269+ cfg.Workspaces = []ports.WorkspaceConfig{ws}
270+ r.reload <- ports.ConfigChanged{Config: cfg}
271+ if v := receive(t, r.spawn); v.Argv[0] != "btop" {
272+ t.Fatal(v)
273+ }
274+ receive(t, r.scenes)
275+ noSpawn(t, r.spawn)
276+}
277+
278+// Binds on the workspace on screen do not refill its empty slots; only
279+// showing it again does.
280+func TestSlotNotRefilledByOtherBinds(t *testing.T) {
281+ r := startSlots(t)
282+ r.fill(t)
283+ r.press(t, "d")
284+ r.client <- ports.WindowUnmapped{ID: 3}
285+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
286+ r.sync(t)
287+ noSpawn(t, r.spawn)
288+ r.press(t, "d")
289+ r.press(t, "d")
290+ if v := receive(t, r.spawn); v.Argv[0] != "foot" {
291+ t.Fatal(v)
292+ }
293+}
294+
295+// After a slot window closes, another window with the old token (a child
296+// of the slot app) opens as a normal window, not in the empty slot.
297+func TestSlotOldTokenIsNormal(t *testing.T) {
298+ r := startSlots(t)
299+ _, foot := r.fill(t)
300+ r.client <- ports.WindowUnmapped{ID: 3}
301+ r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
302+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
303+ if got := visible(s); got[9] == (ports.Rect{}) {
304+ t.Fatalf("old-token window hidden in the slot: %v", got)
305+ }
306+}
307+
308+// A slot whose command never maps a window gets one show to do it; on the
309+// next show it is respawned and its old token no longer fills it.
310+func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
311+ r := startSlots(t)
312+ code, foot := receive(t, r.spawn), receive(t, r.spawn)
313+ r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
314+ scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
315+ r.press(t, "d") // foot still starting: not respawned
316+ noSpawn(t, r.spawn)
317+ r.press(t, "d")
318+ r.press(t, "d")
319+ again := receive(t, r.spawn)
320+ if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
321+ t.Fatal(again)
322+ }
323+ r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
324+ s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
325+ if got := visible(s); got[7] == (ports.Rect{}) {
326+ t.Fatalf("stale-token window not placed as a normal one: %v", got)
327+ }
328+ r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
329+ s = scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
330+ if got := visible(s); got[8].W != 30 {
331+ t.Fatalf("respawned window not in its slot: %v", got)
332+ }
333+}
334+
335+// Moving a slot window to another workspace releases the slot; showing the
336+// workspace again refills it.
337+func TestSlotReleasedWhenWindowMoved(t *testing.T) {
338+ r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
339+ r.fill(t)
340+ r.press(t, "d") // on dev, focus on code (slot 1)
341+ r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
342+ receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1
343+ noSpawn(t, r.spawn)
344+ r.press(t, "d") // back to workspace 1
345+ r.press(t, "d") // dev again: slot 1 is empty
346+ if v := receive(t, r.spawn); v.Argv[0] != "code" {
347+ t.Fatal(v)
348+ }
349+}
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 +17 -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.
@@ -154,6 +160,12 @@ type SetOutputScale struct {
154160
155161 func (SetOutputScale) clientCommand() {}
156162
163+// SlotsPending tells wayland whether a slot waits for its window. Only then
164+// does it read the SlotEnv of mapping clients.
165+type SlotsPending struct{ Pending bool }
166+
167+func (SlotsPending) clientCommand() {}
168+
157169 // CloseWindow carries core → wayland close requests.
158170 type CloseWindow struct{ ID WindowID }
159171
@@ -214,7 +226,11 @@ type CursorImage struct {
214226 }
215227
216228 // SpawnRequest carries core → launcher process arguments.
217-type SpawnRequest struct{ Argv []string }
229+type SpawnRequest struct {
230+ Argv []string
231+ // Env adds KEY=value entries to the child environment.
232+ Env []string
233+}
218234
219235 // Scene carries core → renderer immutable snapshots with fresh Windows slices.
220236 // 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.
@@ -154,6 +160,12 @@ type SetOutputScale struct {
154 160
155 func (SetOutputScale) clientCommand() {}161 func (SetOutputScale) clientCommand() {}
156 162
163+// SlotsPending tells wayland whether a slot waits for its window. Only then
164+// does it read the SlotEnv of mapping clients.
165+type SlotsPending struct{ Pending bool }
166+
167+func (SlotsPending) clientCommand() {}
168+
157 // CloseWindow carries core → wayland close requests.169 // CloseWindow carries core → wayland close requests.
158 type CloseWindow struct{ ID WindowID }170 type CloseWindow struct{ ID WindowID }
159 171
@@ -214,7 +226,11 @@ type CursorImage struct {
214 }226 }
215 227
216 // SpawnRequest carries core → launcher process arguments.228 // SpawnRequest carries core → launcher process arguments.
217-type SpawnRequest struct{ Argv []string }229+type SpawnRequest struct {
230+ Argv []string
231+ // Env adds KEY=value entries to the child environment.
232+ Env []string
233+}
218 234
219 // Scene carries core → renderer immutable snapshots with fresh Windows slices.235 // Scene carries core → renderer immutable snapshots with fresh Windows slices.
220 // Rects and the output size are logical; the renderer multiplies by Scale.236 // 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