feat(workspaces): declarative slots spawned in the background
workspace.<name>.column.N = <width>, <command> reserves a column for the window of a command. nefertty spawns it at startup with NEFERTTY_SLOT set to a token; the wayland adapter reads the token from the client process environment (PID from socket credentials) and core places the first window of that token in its slot without taking focus. Empty slots are refilled when their workspace is shown, never on their own. Live reload changes only edited slots. Widths accept percentages.
- commit
- bcfef742c76d2f46e53d476fc849070127686eaf
- author
- brice · · 2026-09-24 18:01 UTC
- parent
- f2678e8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +830 -54
M .mockery.yml +7 -0
| @@ -23,3 +23,10 @@ packages: | |||
| 23 | 23 | dir: internal/adapters/drm | |
| 24 | 24 | pkgname: drm | |
| 25 | 25 | 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/drm | 23 | dir: internal/adapters/drm | ||
| 24 | pkgname: drm | 24 | pkgname: drm | ||
| 25 | filename: cursor_plane_mock_test.go | 25 | filename: cursor_plane_mock_test.go | ||
| 26 | + | github.com/bnema/nefertty/internal/adapters/wayland: | |||
| 27 | + | interfaces: | |||
| 28 | + | procEnv: | |||
| 29 | + | config: | |||
| 30 | + | dir: internal/adapters/wayland | |||
| 31 | + | pkgname: wayland | |||
| 32 | + | filename: proc_env_mock_test.go | |||
M examples/config +7 -0
| @@ -39,6 +39,13 @@ layout.presets = 1/3, 1/2, 2/3, 1 | |||
| 39 | 39 | # workspace.dev.max-columns = 3 | |
| 40 | 40 | # workspace.dev.overflow = fixed | |
| 41 | 41 | # bind.cmd+d = workspace dev | |
| 42 | + | # | |
| 43 | + | # Slots: column.N = <width>, <command>. nefertty starts each command in the | |
| 44 | + | # background and puts its first window in column N, without taking focus. | |
| 45 | + | # A closed slot stays empty until the workspace is shown again. Apps that | |
| 46 | + | # reuse a running instance need a new-instance flag. | |
| 47 | + | # workspace.dev.column.1 = 67%, code --new-window | |
| 48 | + | # workspace.dev.column.2 = 33%, foot | |
| 42 | 49 | ||
| 43 | 50 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| 44 | 51 | # The first listed display that is connected and not off is used. | |
| @@ -39,6 +39,13 @@ layout.presets = 1/3, 1/2, 2/3, 1 | |||||
| 39 | # workspace.dev.max-columns = 3 | 39 | # workspace.dev.max-columns = 3 | ||
| 40 | # workspace.dev.overflow = fixed | 40 | # workspace.dev.overflow = fixed | ||
| 41 | # bind.cmd+d = workspace dev | 41 | # bind.cmd+d = workspace dev | ||
| 42 | + | # | |||
| 43 | + | # Slots: column.N = <width>, <command>. nefertty starts each command in the | |||
| 44 | + | # background and puts its first window in column N, without taking focus. | |||
| 45 | + | # A closed slot stays empty until the workspace is shown again. Apps that | |||
| 46 | + | # reuse a running instance need a new-instance flag. | |||
| 47 | + | # workspace.dev.column.1 = 67%, code --new-window | |||
| 48 | + | # workspace.dev.column.2 = 33%, foot | |||
| 42 | 49 | ||||
| 43 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | 50 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | ||
| 44 | # The first listed display that is connected and not off is used. | 51 | # The first listed display that is connected and not off is used. | ||
M internal/adapters/config/config.go +27 -2
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "regexp" | |
| 12 | + | "slices" | |
| 12 | 13 | "sort" | |
| 13 | 14 | "strconv" | |
| 14 | 15 | "strings" | |
| @@ -404,7 +405,7 @@ func set(c *ports.Config, key, v string) error { | |||
| 404 | 405 | } | |
| 405 | 406 | for _, s := range list { | |
| 406 | 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 | 411 | c.Layout.Presets = list | |
| @@ -482,7 +483,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 482 | 483 | } | |
| 483 | 484 | w.Overflow = v | |
| 484 | 485 | default: | |
| 485 | - | return fmt.Errorf("unknown key (hidden, max-columns, overflow)") | |
| 486 | + | n, ok := strings.CutPrefix(field, "column.") | |
| 487 | + | if !ok { | |
| 488 | + | return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)") | |
| 489 | + | } | |
| 490 | + | index, err := strconv.Atoi(n) | |
| 491 | + | if err != nil || index < 1 || index > 16 { | |
| 492 | + | return fmt.Errorf("column number must be between 1 and 16") | |
| 493 | + | } | |
| 494 | + | size, cmd, _ := strings.Cut(v, ",") | |
| 495 | + | size = strings.TrimSpace(size) | |
| 496 | + | argv := strings.Fields(cmd) | |
| 497 | + | if !width(size) || len(argv) == 0 { | |
| 498 | + | return fmt.Errorf("must be <width>, <command> like 67%%, foot") | |
| 499 | + | } | |
| 500 | + | slot := ports.SlotConfig{Index: index, Width: size, Argv: argv} | |
| 501 | + | i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index }) | |
| 502 | + | if i < len(w.Slots) && w.Slots[i].Index == index { | |
| 503 | + | w.Slots[i] = slot | |
| 504 | + | } else { | |
| 505 | + | w.Slots = slices.Insert(w.Slots, i, slot) | |
| 506 | + | } | |
| 486 | 507 | } | |
| 487 | 508 | return nil | |
| 488 | 509 | } | |
| @@ -680,6 +701,10 @@ func width(s string) bool { | |||
| 680 | 701 | if s == "1" { | |
| 681 | 702 | return true | |
| 682 | 703 | } | |
| 704 | + | if n, ok := strings.CutSuffix(s, "%"); ok { | |
| 705 | + | v, e := strconv.Atoi(n) | |
| 706 | + | return e == nil && v > 0 && v <= 100 | |
| 707 | + | } | |
| 683 | 708 | if strings.HasSuffix(s, "px") { | |
| 684 | 709 | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | |
| 685 | 710 | 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 = list | 411 | c.Layout.Presets = list | ||
| @@ -482,7 +483,27 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||||
| 482 | } | 483 | } | ||
| 483 | w.Overflow = v | 484 | w.Overflow = v | ||
| 484 | default: | 485 | default: | ||
| 485 | - | return fmt.Errorf("unknown key (hidden, max-columns, overflow)") | 486 | + | n, ok := strings.CutPrefix(field, "column.") |
| 487 | + | if !ok { | |||
| 488 | + | return fmt.Errorf("unknown key (hidden, max-columns, overflow, column.N)") | |||
| 489 | + | } | |||
| 490 | + | index, err := strconv.Atoi(n) | |||
| 491 | + | if err != nil || index < 1 || index > 16 { | |||
| 492 | + | return fmt.Errorf("column number must be between 1 and 16") | |||
| 493 | + | } | |||
| 494 | + | size, cmd, _ := strings.Cut(v, ",") | |||
| 495 | + | size = strings.TrimSpace(size) | |||
| 496 | + | argv := strings.Fields(cmd) | |||
| 497 | + | if !width(size) || len(argv) == 0 { | |||
| 498 | + | return fmt.Errorf("must be <width>, <command> like 67%%, foot") | |||
| 499 | + | } | |||
| 500 | + | slot := ports.SlotConfig{Index: index, Width: size, Argv: argv} | |||
| 501 | + | i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index }) | |||
| 502 | + | if i < len(w.Slots) && w.Slots[i].Index == index { | |||
| 503 | + | w.Slots[i] = slot | |||
| 504 | + | } else { | |||
| 505 | + | w.Slots = slices.Insert(w.Slots, i, slot) | |||
| 506 | + | } | |||
| 486 | } | 507 | } | ||
| 487 | return nil | 508 | return nil | ||
| 488 | } | 509 | } | ||
| @@ -680,6 +701,10 @@ func width(s string) bool { | |||||
| 680 | if s == "1" { | 701 | if s == "1" { | ||
| 681 | return true | 702 | return true | ||
| 682 | } | 703 | } | ||
| 704 | + | if n, ok := strings.CutSuffix(s, "%"); ok { | |||
| 705 | + | v, e := strconv.Atoi(n) | |||
| 706 | + | return e == nil && v > 0 && v <= 100 | |||
| 707 | + | } | |||
| 683 | if strings.HasSuffix(s, "px") { | 708 | if strings.HasSuffix(s, "px") { | ||
| 684 | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | 709 | n, e := strconv.Atoi(strings.TrimSuffix(s, "px")) | ||
| 685 | return e == nil && n > 0 | 710 | return e == nil && n > 0 | ||
M internal/adapters/config/config_test.go +26 -0
| @@ -267,3 +267,29 @@ workspace.lost.hidden = on | |||
| 267 | 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 | + | } | |
| @@ -267,3 +267,29 @@ workspace.lost.hidden = on | |||||
| 267 | t.Fatal(w) | 267 | t.Fatal(w) | ||
| 268 | } | 268 | } | ||
| 269 | } | 269 | } | ||
| 270 | + | ||||
| 271 | + | func TestWorkspaceSlots(t *testing.T) { | |||
| 272 | + | c, w := parseString(t, `workspace.dev.hidden = on | |||
| 273 | + | workspace.dev.column.2 = 33%, foot --title x | |||
| 274 | + | workspace.dev.column.1 = 67%, code --new-window | |||
| 275 | + | workspace.dev.column.2 = 1/3, kitty | |||
| 276 | + | workspace.dev.column.0 = 50%, foot | |||
| 277 | + | workspace.dev.column.3 = 50% | |||
| 278 | + | workspace.dev.column.4 = huge, foot | |||
| 279 | + | bind.cmd+d = workspace dev | |||
| 280 | + | `) | |||
| 281 | + | want := []ports.SlotConfig{ | |||
| 282 | + | {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}}, | |||
| 283 | + | {Index: 2, Width: "1/3", Argv: []string{"kitty"}}, | |||
| 284 | + | } | |||
| 285 | + | if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) { | |||
| 286 | + | t.Fatalf("%+v", c.Workspaces) | |||
| 287 | + | } | |||
| 288 | + | lines := []int{} | |||
| 289 | + | for _, x := range w { | |||
| 290 | + | lines = append(lines, x.Line) | |||
| 291 | + | } | |||
| 292 | + | if !reflect.DeepEqual(lines, []int{4, 5, 6, 7}) { | |||
| 293 | + | t.Fatal(w) | |||
| 294 | + | } | |||
| 295 | + | } | |||
M internal/adapters/launcher/launcher.go +1 -1
| @@ -70,7 +70,7 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro | |||
| 70 | 70 | continue | |
| 71 | 71 | } | |
| 72 | 72 | cmd := exec.Command(path, req.Argv[1:]...) | |
| 73 | - | cmd.Env = l.env | |
| 73 | + | cmd.Env = append(append([]string(nil), l.env...), req.Env...) | |
| 74 | 74 | cmd.Stdin = nil | |
| 75 | 75 | cmd.Stdout = nil | |
| 76 | 76 | cmd.Stderr = nil | |
| @@ -70,7 +70,7 @@ func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) erro | |||||
| 70 | continue | 70 | continue | ||
| 71 | } | 71 | } | ||
| 72 | cmd := exec.Command(path, req.Argv[1:]...) | 72 | cmd := exec.Command(path, req.Argv[1:]...) | ||
| 73 | - | cmd.Env = l.env | 73 | + | cmd.Env = append(append([]string(nil), l.env...), req.Env...) |
| 74 | cmd.Stdin = nil | 74 | cmd.Stdin = nil | ||
| 75 | cmd.Stdout = nil | 75 | cmd.Stdout = nil | ||
| 76 | cmd.Stderr = nil | 76 | cmd.Stderr = nil | ||
M internal/adapters/launcher/launcher_test.go +3 -2
| @@ -31,12 +31,13 @@ func TestRun(t *testing.T) { | |||
| 31 | 31 | }() | |
| 32 | 32 | reqs <- ports.SpawnRequest{} | |
| 33 | 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 | 36 | deadline := time.After(3 * time.Second) | |
| 36 | 37 | for { | |
| 37 | 38 | data, err := os.ReadFile(file) | |
| 38 | 39 | if err == nil { | |
| 39 | - | if string(data) != "ok\n" { | |
| 40 | + | if string(data) != "ok7\n" { | |
| 40 | 41 | t.Fatalf("output: %q", data) | |
| 41 | 42 | } | |
| 42 | 43 | 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 | break | 43 | break | ||
M internal/adapters/wayland/keyboard_test.go +5 -1
| @@ -26,7 +26,8 @@ func keymapText(t *testing.T) string { | |||
| 26 | 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 | 31 | t.Helper() | |
| 31 | 32 | dir := t.TempDir() | |
| 32 | 33 | events := make(chan ports.ClientEvent, 16) | |
| @@ -35,6 +36,9 @@ func keyboardServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.C | |||
| 35 | 36 | if err != nil { | |
| 36 | 37 | t.Fatal(err) | |
| 37 | 38 | } | |
| 39 | + | for _, f := range setup { | |
| 40 | + | f(s) | |
| 41 | + | } | |
| 38 | 42 | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | 43 | done := make(chan error, 1) | |
| 40 | 44 | 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) { | |||
| 474 | 474 | kp.SetID(keyboard) | |
| 475 | 475 | c.Context().Register(kp) | |
| 476 | 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 | 517 | comp := bindProtocol(t, c, "wl_compositor") | |
| 478 | 518 | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 479 | 519 | shm := bindProtocol(t, c, "wl_shm") | |
| @@ -493,7 +533,7 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) { | |||
| 493 | 533 | t.Fatal(err) | |
| 494 | 534 | } | |
| 495 | 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 | 537 | surf, xdg, top := c.AllocateID(), c.AllocateID(), c.AllocateID() | |
| 498 | 538 | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 499 | 539 | registerProtocol(t, c, surf) | |
| @@ -519,39 +559,6 @@ func TestHeldKeyOnKeyboardFocusTransfer(t *testing.T) { | |||
| 519 | 559 | if err := c.Roundtrip(); err != nil { | |
| 520 | 560 | t.Fatal(err) | |
| 521 | 561 | } | |
| 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) | |
| 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 | } | |
| @@ -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).ID | 562 | + | return mapped(t, events, 2*time.Second) |
| 523 | - | } | |||
| 524 | - | a := mapWindow() | |||
| 525 | - | b := mapWindow() | |||
| 526 | - | // Dispatch without sending a new request after focus changes. | |||
| 527 | - | dispatched := make(chan error, 1) | |||
| 528 | - | go func() { | |||
| 529 | - | for { | |||
| 530 | - | if err := c.Dispatch(); err != nil { | |||
| 531 | - | dispatched <- err | |||
| 532 | - | return | |||
| 533 | - | } | |||
| 534 | - | } | |||
| 535 | - | }() | |||
| 536 | - | enter := func(want []byte) { | |||
| 537 | - | t.Helper() | |||
| 538 | - | select { | |||
| 539 | - | case got := <-kp.enters: | |||
| 540 | - | if !bytes.Equal(got, want) { | |||
| 541 | - | t.Fatalf("keyboard enter keys = %v, want %v", got, want) | |||
| 542 | - | } | |||
| 543 | - | case err := <-dispatched: | |||
| 544 | - | t.Fatal(err) | |||
| 545 | - | case <-time.After(2 * time.Second): | |||
| 546 | - | t.Fatal("keyboard enter timeout") | |||
| 547 | - | } | |||
| 548 | } | 563 | } | ||
| 549 | - | commands <- ports.FocusWindow{ID: a} | |||
| 550 | - | enter(nil) | |||
| 551 | - | commands <- ports.ForwardKey{ID: a, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 1}} | |||
| 552 | - | commands <- ports.FocusWindow{ID: b} | |||
| 553 | - | enter([]byte{30, 0, 0, 0}) | |||
| 554 | - | commands <- ports.ForwardKey{ID: b, Key: ports.KeyEvent{Keycode: 30, TimeMsec: 2}} | |||
| 555 | - | commands <- ports.FocusWindow{ID: a} | |||
| 556 | - | enter(nil) | |||
| 557 | } | 564 | } | ||
M internal/adapters/wayland/server.go +2 -1
| @@ -54,6 +54,7 @@ type Channels struct { | |||
| 54 | 54 | } | |
| 55 | 55 | type Server struct { | |
| 56 | 56 | display *server.Display | |
| 57 | + | env procEnv | |
| 57 | 58 | name string | |
| 58 | 59 | cleanup func() | |
| 59 | 60 | log zerowrap.Logger | |
| @@ -125,7 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 125 | 126 | d.Close() | |
| 126 | 127 | return nil, err | |
| 127 | 128 | } | |
| 128 | - | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 129 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 129 | 130 | s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}} | |
| 130 | 131 | if opts.Keymap != "" { | |
| 131 | 132 | fd, size, e := keymapFile(opts.Keymap) | |
| @@ -54,6 +54,7 @@ type Channels struct { | |||||
| 54 | } | 54 | } | ||
| 55 | type Server struct { | 55 | type Server struct { | ||
| 56 | display *server.Display | 56 | display *server.Display | ||
| 57 | + | env procEnv | |||
| 57 | name string | 58 | name string | ||
| 58 | cleanup func() | 59 | cleanup func() | ||
| 59 | log zerowrap.Logger | 60 | log zerowrap.Logger | ||
| @@ -125,7 +126,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 125 | d.Close() | 126 | d.Close() | ||
| 126 | return nil, err | 127 | return nil, err | ||
| 127 | } | 128 | } | ||
| 128 | - | s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 129 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} |
| 129 | s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}} | 130 | s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}} | ||
| 130 | if opts.Keymap != "" { | 131 | if opts.Keymap != "" { | ||
| 131 | fd, size, e := keymapFile(opts.Keymap) | 132 | fd, size, e := keymapFile(opts.Keymap) | ||
A internal/adapters/wayland/slot.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "strconv" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // procEnv reads one environment variable of a running process. | |
| 12 | + | type procEnv interface { | |
| 13 | + | Lookup(pid int, key string) (string, bool) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // linuxProcEnv reads /proc/<pid>/environ: the environment the process started | |
| 17 | + | // with. A slot command that forks its window process passes it down, since | |
| 18 | + | // children inherit the environment. | |
| 19 | + | type linuxProcEnv struct{} | |
| 20 | + | ||
| 21 | + | func (linuxProcEnv) Lookup(pid int, key string) (string, bool) { | |
| 22 | + | if pid <= 0 { | |
| 23 | + | return "", false | |
| 24 | + | } | |
| 25 | + | data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ") | |
| 26 | + | if err != nil { | |
| 27 | + | return "", false | |
| 28 | + | } | |
| 29 | + | prefix := []byte(key + "=") | |
| 30 | + | for entry := range bytes.SplitSeq(data, []byte{0}) { | |
| 31 | + | if v, ok := bytes.CutPrefix(entry, prefix); ok { | |
| 32 | + | return string(v), true | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | return "", false | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // slotToken returns the SlotEnv value of the client process, if any. | |
| 39 | + | func (s *Server) slotToken(pid int) string { | |
| 40 | + | v, _ := s.env.Lookup(pid, ports.SlotEnv) | |
| 41 | + | return v | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "os" | |||
| 6 | + | "strconv" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // procEnv reads one environment variable of a running process. | |||
| 12 | + | type procEnv interface { | |||
| 13 | + | Lookup(pid int, key string) (string, bool) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // linuxProcEnv reads /proc/<pid>/environ: the environment the process started | |||
| 17 | + | // with. A slot command that forks its window process passes it down, since | |||
| 18 | + | // children inherit the environment. | |||
| 19 | + | type linuxProcEnv struct{} | |||
| 20 | + | ||||
| 21 | + | func (linuxProcEnv) Lookup(pid int, key string) (string, bool) { | |||
| 22 | + | if pid <= 0 { | |||
| 23 | + | return "", false | |||
| 24 | + | } | |||
| 25 | + | data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ") | |||
| 26 | + | if err != nil { | |||
| 27 | + | return "", false | |||
| 28 | + | } | |||
| 29 | + | prefix := []byte(key + "=") | |||
| 30 | + | for entry := range bytes.SplitSeq(data, []byte{0}) { | |||
| 31 | + | if v, ok := bytes.CutPrefix(entry, prefix); ok { | |||
| 32 | + | return string(v), true | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | return "", false | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // slotToken returns the SlotEnv value of the client process, if any. | |||
| 39 | + | func (s *Server) slotToken(pid int) string { | |||
| 40 | + | v, _ := s.env.Lookup(pid, ports.SlotEnv) | |||
| 41 | + | return v | |||
| 42 | + | } | |||
A internal/adapters/wayland/slot_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A mapped window carries the slot token of its client process. | |
| 11 | + | func TestWindowMappedCarriesSlot(t *testing.T) { | |
| 12 | + | env := newMockprocEnv(t) | |
| 13 | + | // The test client runs in this process: its PID is ours. | |
| 14 | + | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once() | |
| 15 | + | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once() | |
| 16 | + | s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env }) | |
| 17 | + | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | |
| 18 | + | if w := mapWindow(); w.Slot != "7" { | |
| 19 | + | t.Fatalf("slot %q", w.Slot) | |
| 20 | + | } | |
| 21 | + | if w := mapWindow(); w.Slot != "" { | |
| 22 | + | t.Fatalf("slot %q", w.Slot) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestLinuxProcEnv(t *testing.T) { | |
| 27 | + | t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env | |
| 28 | + | if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok { | |
| 29 | + | t.Fatal("environ reflects later changes") | |
| 30 | + | } | |
| 31 | + | path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH") | |
| 32 | + | if want, _ := os.LookupEnv("PATH"); ok != (want != "") { | |
| 33 | + | t.Fatalf("PATH %q %v", path, ok) | |
| 34 | + | } | |
| 35 | + | if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok { | |
| 36 | + | t.Fatal("pid 0") | |
| 37 | + | } | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A mapped window carries the slot token of its client process. | |||
| 11 | + | func TestWindowMappedCarriesSlot(t *testing.T) { | |||
| 12 | + | env := newMockprocEnv(t) | |||
| 13 | + | // The test client runs in this process: its PID is ours. | |||
| 14 | + | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("7", true).Once() | |||
| 15 | + | env.EXPECT().Lookup(os.Getpid(), ports.SlotEnv).Return("", false).Once() | |||
| 16 | + | s, events, _, dir := keyboardServer(t, func(s *Server) { s.env = env }) | |||
| 17 | + | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | |||
| 18 | + | if w := mapWindow(); w.Slot != "7" { | |||
| 19 | + | t.Fatalf("slot %q", w.Slot) | |||
| 20 | + | } | |||
| 21 | + | if w := mapWindow(); w.Slot != "" { | |||
| 22 | + | t.Fatalf("slot %q", w.Slot) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestLinuxProcEnv(t *testing.T) { | |||
| 27 | + | t.Setenv("NEFERTTY_TEST_KEY", "x") // not in /proc: environ is the start env | |||
| 28 | + | if _, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "NEFERTTY_TEST_KEY"); ok { | |||
| 29 | + | t.Fatal("environ reflects later changes") | |||
| 30 | + | } | |||
| 31 | + | path, ok := (linuxProcEnv{}).Lookup(os.Getpid(), "PATH") | |||
| 32 | + | if want, _ := os.LookupEnv("PATH"); ok != (want != "") { | |||
| 33 | + | t.Fatalf("PATH %q %v", path, ok) | |||
| 34 | + | } | |||
| 35 | + | if _, ok := (linuxProcEnv{}).Lookup(0, "PATH"); ok { | |||
| 36 | + | t.Fatal("pid 0") | |||
| 37 | + | } | |||
| 38 | + | } | |||
M internal/adapters/wayland/xdg.go +3 -2
| @@ -150,8 +150,9 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 150 | 150 | x.serials = append(x.serials, x.server.serial) | |
| 151 | 151 | } else if buffer && !w.mapped && x.acked { | |
| 152 | 152 | w.mapped = true | |
| 153 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID}) | |
| 154 | - | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped") | |
| 153 | + | slot := x.server.slotToken(r.Client().PID()) | |
| 154 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot}) | |
| 155 | + | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | |
| 155 | 156 | } else if !buffer && w.mapped { | |
| 156 | 157 | w.unmap() | |
| 157 | 158 | } | |
| @@ -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 = true | 152 | w.mapped = true | ||
| 153 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID}) | 153 | + | slot := x.server.slotToken(r.Client().PID()) |
| 154 | - | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped") | 154 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot}) |
| 155 | + | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | |||
| 155 | } else if !buffer && w.mapped { | 156 | } else if !buffer && w.mapped { | ||
| 156 | w.unmap() | 157 | w.unmap() | ||
| 157 | } | 158 | } | ||
M internal/core/core.go +41 -2
| @@ -50,6 +50,9 @@ type Core struct { | |||
| 50 | 50 | physW, physH int | |
| 51 | 51 | scale, cfgScale float64 | |
| 52 | 52 | sentScale ports.SetOutputScale | |
| 53 | + | slots slots | |
| 54 | + | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 55 | + | toSpawn []slotKey | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | func keyName(s string) string { | |
| @@ -169,6 +172,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 169 | 172 | seen[ws.Name] = true | |
| 170 | 173 | named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o}) | |
| 171 | 174 | } | |
| 175 | + | specs, err := parseSlots(cfg.Workspaces) | |
| 176 | + | if err != nil { | |
| 177 | + | return err | |
| 178 | + | } | |
| 172 | 179 | c.cfg = cfg | |
| 173 | 180 | c.binds = binds | |
| 174 | 181 | c.applyConfigScale() | |
| @@ -177,13 +184,32 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 177 | 184 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | |
| 178 | 185 | c.ws.SetPresets(presets) | |
| 179 | 186 | c.ws.SetGaps(cfg.Layout.Gaps) | |
| 187 | + | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 188 | + | return nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // spawnSlots starts pending slot commands, then the empty slots of the | |
| 192 | + | // workspace on screen. | |
| 193 | + | func (c *Core) spawnSlots(ctx context.Context) error { | |
| 194 | + | keys := append(c.toSpawn, c.refill()...) | |
| 195 | + | c.toSpawn = nil | |
| 196 | + | for _, key := range keys { | |
| 197 | + | if _, ok := c.slots.byKey[key]; !ok { | |
| 198 | + | continue | |
| 199 | + | } | |
| 200 | + | select { | |
| 201 | + | case <-ctx.Done(): | |
| 202 | + | return ctx.Err() | |
| 203 | + | case c.ch.Spawn <- c.spawnSlot(key): | |
| 204 | + | } | |
| 205 | + | } | |
| 180 | 206 | return nil | |
| 181 | 207 | } | |
| 182 | 208 | func New(cfg ports.Config, ch Channels) (*Core, error) { | |
| 183 | 209 | if cap(ch.Scenes) != 1 { | |
| 184 | 210 | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 185 | 211 | } | |
| 186 | - | c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 212 | + | c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 187 | 213 | if err := c.apply(cfg); err != nil { | |
| 188 | 214 | return nil, err | |
| 189 | 215 | } | |
| @@ -334,6 +360,9 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 334 | 360 | return nil | |
| 335 | 361 | } | |
| 336 | 362 | func (c *Core) Run(ctx context.Context) error { | |
| 363 | + | if c.spawnSlots(ctx) != nil { | |
| 364 | + | return nil | |
| 365 | + | } | |
| 337 | 366 | for { | |
| 338 | 367 | select { | |
| 339 | 368 | case <-ctx.Done(): | |
| @@ -351,9 +380,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 351 | 380 | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 352 | 381 | c.ws.SetUsable(usable) | |
| 353 | 382 | case ports.WindowMapped: | |
| 354 | - | c.ws.AddWindow(v.ID) | |
| 383 | + | if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) { | |
| 384 | + | c.ws.AddWindow(v.ID) | |
| 385 | + | } | |
| 355 | 386 | case ports.WindowUnmapped: | |
| 356 | 387 | c.ws.RemoveWindow(v.ID) | |
| 388 | + | c.slotWindowGone(v.ID) | |
| 357 | 389 | if c.pointer == v.ID { | |
| 358 | 390 | c.pointer = 0 | |
| 359 | 391 | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | |
| @@ -400,6 +432,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 400 | 432 | } | |
| 401 | 433 | continue | |
| 402 | 434 | } | |
| 435 | + | if c.spawnSlots(ctx) != nil { | |
| 436 | + | return nil | |
| 437 | + | } | |
| 403 | 438 | if err := c.publish(ctx); err != nil { | |
| 404 | 439 | return nil | |
| 405 | 440 | } | |
| @@ -523,6 +558,10 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 523 | 558 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | |
| 524 | 559 | } | |
| 525 | 560 | } | |
| 561 | + | // Showing a declared workspace refills its empty slots. | |
| 562 | + | if c.spawnSlots(ctx) != nil { | |
| 563 | + | return nil | |
| 564 | + | } | |
| 526 | 565 | if effect.Close != 0 { | |
| 527 | 566 | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | |
| 528 | 567 | return nil | |
| @@ -50,6 +50,9 @@ type Core struct { | |||||
| 50 | physW, physH int | 50 | physW, physH int | ||
| 51 | scale, cfgScale float64 | 51 | scale, cfgScale float64 | ||
| 52 | sentScale ports.SetOutputScale | 52 | sentScale ports.SetOutputScale | ||
| 53 | + | slots slots | |||
| 54 | + | // toSpawn holds slots to start; Run sends them (apply has no context). | |||
| 55 | + | toSpawn []slotKey | |||
| 53 | } | 56 | } | ||
| 54 | 57 | ||||
| 55 | func keyName(s string) string { | 58 | func keyName(s string) string { | ||
| @@ -169,6 +172,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 169 | seen[ws.Name] = true | 172 | seen[ws.Name] = true | ||
| 170 | named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o}) | 173 | named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o}) | ||
| 171 | } | 174 | } | ||
| 175 | + | specs, err := parseSlots(cfg.Workspaces) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return err | |||
| 178 | + | } | |||
| 172 | c.cfg = cfg | 179 | c.cfg = cfg | ||
| 173 | c.binds = binds | 180 | c.binds = binds | ||
| 174 | c.applyConfigScale() | 181 | c.applyConfigScale() | ||
| @@ -177,13 +184,32 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 177 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | 184 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | ||
| 178 | c.ws.SetPresets(presets) | 185 | c.ws.SetPresets(presets) | ||
| 179 | c.ws.SetGaps(cfg.Layout.Gaps) | 186 | c.ws.SetGaps(cfg.Layout.Gaps) | ||
| 187 | + | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |||
| 188 | + | return nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // spawnSlots starts pending slot commands, then the empty slots of the | |||
| 192 | + | // workspace on screen. | |||
| 193 | + | func (c *Core) spawnSlots(ctx context.Context) error { | |||
| 194 | + | keys := append(c.toSpawn, c.refill()...) | |||
| 195 | + | c.toSpawn = nil | |||
| 196 | + | for _, key := range keys { | |||
| 197 | + | if _, ok := c.slots.byKey[key]; !ok { | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | select { | |||
| 201 | + | case <-ctx.Done(): | |||
| 202 | + | return ctx.Err() | |||
| 203 | + | case c.ch.Spawn <- c.spawnSlot(key): | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 180 | return nil | 206 | return nil | ||
| 181 | } | 207 | } | ||
| 182 | func New(cfg ports.Config, ch Channels) (*Core, error) { | 208 | func New(cfg ports.Config, ch Channels) (*Core, error) { | ||
| 183 | if cap(ch.Scenes) != 1 { | 209 | if cap(ch.Scenes) != 1 { | ||
| 184 | return nil, fmt.Errorf("scenes must have capacity 1") | 210 | return nil, fmt.Errorf("scenes must have capacity 1") | ||
| 185 | } | 211 | } | ||
| 186 | - | c := &Core{scale: 1, ws: NewMonitor(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 212 | + | c := &Core{scale: 1, ws: NewMonitor(), slots: newSlots(), ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 187 | if err := c.apply(cfg); err != nil { | 213 | if err := c.apply(cfg); err != nil { | ||
| 188 | return nil, err | 214 | return nil, err | ||
| 189 | } | 215 | } | ||
| @@ -334,6 +360,9 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 334 | return nil | 360 | return nil | ||
| 335 | } | 361 | } | ||
| 336 | func (c *Core) Run(ctx context.Context) error { | 362 | func (c *Core) Run(ctx context.Context) error { | ||
| 363 | + | if c.spawnSlots(ctx) != nil { | |||
| 364 | + | return nil | |||
| 365 | + | } | |||
| 337 | for { | 366 | for { | ||
| 338 | select { | 367 | select { | ||
| 339 | case <-ctx.Done(): | 368 | case <-ctx.Done(): | ||
| @@ -351,9 +380,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 351 | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | 380 | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | ||
| 352 | c.ws.SetUsable(usable) | 381 | c.ws.SetUsable(usable) | ||
| 353 | case ports.WindowMapped: | 382 | case ports.WindowMapped: | ||
| 354 | - | c.ws.AddWindow(v.ID) | 383 | + | if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) { |
| 384 | + | c.ws.AddWindow(v.ID) | |||
| 385 | + | } | |||
| 355 | case ports.WindowUnmapped: | 386 | case ports.WindowUnmapped: | ||
| 356 | c.ws.RemoveWindow(v.ID) | 387 | c.ws.RemoveWindow(v.ID) | ||
| 388 | + | c.slotWindowGone(v.ID) | |||
| 357 | if c.pointer == v.ID { | 389 | if c.pointer == v.ID { | ||
| 358 | c.pointer = 0 | 390 | c.pointer = 0 | ||
| 359 | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | 391 | if err := c.command(ctx, ports.PointerFocus{}); err != nil { | ||
| @@ -400,6 +432,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 400 | } | 432 | } | ||
| 401 | continue | 433 | continue | ||
| 402 | } | 434 | } | ||
| 435 | + | if c.spawnSlots(ctx) != nil { | |||
| 436 | + | return nil | |||
| 437 | + | } | |||
| 403 | if err := c.publish(ctx); err != nil { | 438 | if err := c.publish(ctx); err != nil { | ||
| 404 | return nil | 439 | return nil | ||
| 405 | } | 440 | } | ||
| @@ -523,6 +558,10 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 523 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | 558 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | ||
| 524 | } | 559 | } | ||
| 525 | } | 560 | } | ||
| 561 | + | // Showing a declared workspace refills its empty slots. | |||
| 562 | + | if c.spawnSlots(ctx) != nil { | |||
| 563 | + | return nil | |||
| 564 | + | } | |||
| 526 | if effect.Close != 0 { | 565 | if effect.Close != 0 { | ||
| 527 | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | 566 | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | ||
| 528 | return nil | 567 | return nil | ||
M internal/core/layout.go +47 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "github.com/bnema/nefertty/internal/ports" | |
| 6 | 6 | "math/bits" | |
| 7 | + | "slices" | |
| 7 | 8 | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ) | |
| @@ -23,6 +24,11 @@ func ParseWidth(s string) (Width, error) { | |||
| 23 | 24 | if e == nil && n > 0 { | |
| 24 | 25 | 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 | 32 | } else { | |
| 27 | 33 | p := strings.Split(s, "/") | |
| 28 | 34 | if len(p) == 2 { | |
| @@ -76,6 +82,9 @@ type Column struct { | |||
| 76 | 82 | Windows []WindowID | |
| 77 | 83 | Width Width | |
| 78 | 84 | 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 | 89 | type Placement struct { | |
| 81 | 90 | ID WindowID | |
| @@ -154,6 +163,44 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 154 | 163 | w.Focus = at | |
| 155 | 164 | w.scroll() | |
| 156 | 165 | } | |
| 166 | + | ||
| 167 | + | // AddSlotWindow places a slot window in its own column, ordered by slot | |
| 168 | + | // number before the other columns. Focus stays on the focused window: slot | |
| 169 | + | // windows are automatic events (ADR 011 golden rule). | |
| 170 | + | func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |
| 171 | + | if id == 0 || w.has(id) { | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | at := 0 | |
| 175 | + | for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot { | |
| 176 | + | at++ | |
| 177 | + | } | |
| 178 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot}) | |
| 179 | + | if len(w.Columns) > 1 && at <= w.Focus { | |
| 180 | + | w.Focus++ | |
| 181 | + | } | |
| 182 | + | w.scroll() | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // setSlotWidth updates the width of slot column n, if present. | |
| 186 | + | func (w *Workspace) setSlotWidth(n int, width Width) { | |
| 187 | + | for i := range w.Columns { | |
| 188 | + | if w.Columns[i].Slot == n { | |
| 189 | + | w.Columns[i].Width = width | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | w.scroll() | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // unslot turns slot column n into a normal column. | |
| 196 | + | func (w *Workspace) unslot(n int) { | |
| 197 | + | for i := range w.Columns { | |
| 198 | + | if w.Columns[i].Slot == n { | |
| 199 | + | w.Columns[i].Slot = 0 | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 157 | 204 | func (w *Workspace) RemoveWindow(id WindowID) { | |
| 158 | 205 | for i := range w.Columns { | |
| 159 | 206 | 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}, nil | 25 | 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 []WindowID | 82 | Windows []WindowID | ||
| 77 | Width Width | 83 | Width Width | ||
| 78 | Focus int | 84 | 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 WindowID | 90 | ID WindowID | ||
| @@ -154,6 +163,44 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 154 | w.Focus = at | 163 | w.Focus = at | ||
| 155 | w.scroll() | 164 | w.scroll() | ||
| 156 | } | 165 | } | ||
| 166 | + | ||||
| 167 | + | // AddSlotWindow places a slot window in its own column, ordered by slot | |||
| 168 | + | // number before the other columns. Focus stays on the focused window: slot | |||
| 169 | + | // windows are automatic events (ADR 011 golden rule). | |||
| 170 | + | func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |||
| 171 | + | if id == 0 || w.has(id) { | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | at := 0 | |||
| 175 | + | for at < len(w.Columns) && w.Columns[at].Slot > 0 && w.Columns[at].Slot < slot { | |||
| 176 | + | at++ | |||
| 177 | + | } | |||
| 178 | + | w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot}) | |||
| 179 | + | if len(w.Columns) > 1 && at <= w.Focus { | |||
| 180 | + | w.Focus++ | |||
| 181 | + | } | |||
| 182 | + | w.scroll() | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // setSlotWidth updates the width of slot column n, if present. | |||
| 186 | + | func (w *Workspace) setSlotWidth(n int, width Width) { | |||
| 187 | + | for i := range w.Columns { | |||
| 188 | + | if w.Columns[i].Slot == n { | |||
| 189 | + | w.Columns[i].Width = width | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | w.scroll() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // unslot turns slot column n into a normal column. | |||
| 196 | + | func (w *Workspace) unslot(n int) { | |||
| 197 | + | for i := range w.Columns { | |||
| 198 | + | if w.Columns[i].Slot == n { | |||
| 199 | + | w.Columns[i].Slot = 0 | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 157 | func (w *Workspace) RemoveWindow(id WindowID) { | 204 | func (w *Workspace) RemoveWindow(id WindowID) { | ||
| 158 | for i := range w.Columns { | 205 | for i := range w.Columns { | ||
| 159 | for j, v := range w.Columns[i].Windows { | 206 | for j, v := range w.Columns[i].Windows { | ||
M internal/core/monitor.go +11 -6
| @@ -92,12 +92,7 @@ func (m *Monitor) show(w *Workspace) { | |||
| 92 | 92 | // ToggleNamed shows the named workspace, or returns to the previous one when | |
| 93 | 93 | // it is already on screen. Unknown names do nothing. | |
| 94 | 94 | 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) | |
| 101 | 96 | switch cur := m.Current(); { | |
| 102 | 97 | case target == nil: | |
| 103 | 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 | 122 | func indexOf(list []*Workspace, w *Workspace) int { | |
| 118 | 123 | for i, v := range list { | |
| 119 | 124 | 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 when | 92 | // 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 *Workspace | 95 | + | target := m.byName(name) |
| 96 | - | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |||
| 97 | - | if w.Name == name { | |||
| 98 | - | target = w | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | switch cur := m.Current(); { | 96 | switch cur := m.Current(); { | ||
| 102 | case target == nil: | 97 | case target == nil: | ||
| 103 | case target != cur: | 98 | case target != cur: | ||
| @@ -114,6 +109,16 @@ func (m *Monitor) ToggleNamed(name string) { | |||||
| 114 | } | 109 | } | ||
| 115 | } | 110 | } | ||
| 116 | 111 | ||||
| 112 | + | // byName returns the configured workspace with that name, or nil. | |||
| 113 | + | func (m *Monitor) byName(name string) *Workspace { | |||
| 114 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |||
| 115 | + | if w.Name == name { | |||
| 116 | + | return w | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 117 | func indexOf(list []*Workspace, w *Workspace) int { | 122 | func indexOf(list []*Workspace, w *Workspace) int { | ||
| 118 | for i, v := range list { | 123 | for i, v := range list { | ||
| 119 | if v == w { | 124 | if v == w { | ||
A internal/core/slots.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "slices" | |
| 6 | + | "strconv" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves | |
| 12 | + | // column N for the window of one command. The slot, not the window, has the | |
| 13 | + | // identity: nefertty spawns the command with SlotEnv set to a token, and the | |
| 14 | + | // first window of a client carrying that token fills the slot. | |
| 15 | + | // | |
| 16 | + | // A slot is spawned at startup and when its workspace is shown while it is | |
| 17 | + | // empty and no spawn is pending. Nothing is relaunched on its own. | |
| 18 | + | ||
| 19 | + | type slotKey struct { | |
| 20 | + | workspace string | |
| 21 | + | index int | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type slotState struct { | |
| 25 | + | argv []string | |
| 26 | + | width Width | |
| 27 | + | token string // SlotEnv value of the last spawn; "" before the first | |
| 28 | + | window WindowID // 0 while empty | |
| 29 | + | pending bool // spawned, window not mapped yet | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type slots struct { | |
| 33 | + | byKey map[slotKey]*slotState | |
| 34 | + | // byToken maps spawn tokens (current and past) to their slot, so a | |
| 35 | + | // late window of an old spawn still lands in its slot. | |
| 36 | + | byToken map[string]slotKey | |
| 37 | + | next uint64 | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func newSlots() slots { | |
| 41 | + | return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // slotSpec is one parsed slot of the config. | |
| 45 | + | type slotSpec struct { | |
| 46 | + | key slotKey | |
| 47 | + | argv []string | |
| 48 | + | width Width | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) { | |
| 52 | + | var specs []slotSpec | |
| 53 | + | for _, w := range ws { | |
| 54 | + | for _, s := range w.Slots { | |
| 55 | + | width, err := ParseWidth(s.Width) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err) | |
| 58 | + | } | |
| 59 | + | if len(s.Argv) == 0 || s.Index < 1 { | |
| 60 | + | return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index) | |
| 61 | + | } | |
| 62 | + | specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width}) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return specs, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // updateSlots applies the config. It returns the new slots, which the caller | |
| 69 | + | // spawns; changed commands are respawned only when their slot is empty. | |
| 70 | + | // A slot dropped from config keeps its window as a normal column. | |
| 71 | + | func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |
| 72 | + | want := map[slotKey]slotSpec{} | |
| 73 | + | for _, s := range specs { | |
| 74 | + | want[s.key] = s | |
| 75 | + | } | |
| 76 | + | for key := range c.slots.byKey { | |
| 77 | + | if _, ok := want[key]; ok { | |
| 78 | + | continue | |
| 79 | + | } | |
| 80 | + | if w := c.ws.byName(key.workspace); w != nil { | |
| 81 | + | w.unslot(key.index) | |
| 82 | + | } | |
| 83 | + | c.forgetTokens(key) | |
| 84 | + | delete(c.slots.byKey, key) | |
| 85 | + | } | |
| 86 | + | var spawn []slotKey | |
| 87 | + | for _, s := range specs { | |
| 88 | + | st, ok := c.slots.byKey[s.key] | |
| 89 | + | if !ok { | |
| 90 | + | c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width} | |
| 91 | + | spawn = append(spawn, s.key) | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | if st.width != s.width { | |
| 95 | + | st.width = s.width | |
| 96 | + | if w := c.ws.byName(s.key.workspace); w != nil { | |
| 97 | + | w.setSlotWidth(s.key.index, s.width) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if !slices.Equal(st.argv, s.argv) { | |
| 101 | + | st.argv = s.argv | |
| 102 | + | if st.window == 0 { | |
| 103 | + | // A pending spawn ran the old command: its window is no | |
| 104 | + | // longer the slot's; the new command replaces it. | |
| 105 | + | c.forgetTokens(s.key) | |
| 106 | + | st.pending = false | |
| 107 | + | spawn = append(spawn, s.key) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return spawn | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // forgetTokens drops the spawn tokens of a slot: their windows open as normal ones. | |
| 115 | + | func (c *Core) forgetTokens(key slotKey) { | |
| 116 | + | for tok, k := range c.slots.byToken { | |
| 117 | + | if k == key { | |
| 118 | + | delete(c.slots.byToken, tok) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // spawnSlot starts the slot's command with a fresh token. | |
| 124 | + | func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest { | |
| 125 | + | st := c.slots.byKey[key] | |
| 126 | + | c.slots.next++ | |
| 127 | + | st.token = strconv.FormatUint(c.slots.next, 10) | |
| 128 | + | st.pending = true | |
| 129 | + | c.slots.byToken[st.token] = key | |
| 130 | + | return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}} | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // placeSlotWindow puts a mapped window with a slot token into its slot. It | |
| 134 | + | // returns false when the window is not a slot window (unknown token, slot | |
| 135 | + | // already filled, or its workspace is gone): it then follows normal rules. | |
| 136 | + | func (c *Core) placeSlotWindow(id WindowID, token string) bool { | |
| 137 | + | key, ok := c.slots.byToken[token] | |
| 138 | + | if !ok { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | st := c.slots.byKey[key] | |
| 142 | + | w := c.ws.byName(key.workspace) | |
| 143 | + | if st == nil || st.window != 0 || w == nil { | |
| 144 | + | return false | |
| 145 | + | } | |
| 146 | + | st.window, st.pending = id, false | |
| 147 | + | w.AddSlotWindow(id, key.index, st.width) | |
| 148 | + | c.ws.normalize() | |
| 149 | + | return true | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // slotWindowGone empties the slot of a closed window. The slot stays | |
| 153 | + | // reserved; it is refilled when the user next shows its workspace. | |
| 154 | + | func (c *Core) slotWindowGone(id WindowID) { | |
| 155 | + | for _, st := range c.slots.byKey { | |
| 156 | + | if st.window == id { | |
| 157 | + | st.window = 0 | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // refill returns the empty, idle slots of the workspace on screen. | |
| 163 | + | func (c *Core) refill() []slotKey { | |
| 164 | + | name := c.ws.Current().Name | |
| 165 | + | if name == "" { | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | var keys []slotKey | |
| 169 | + | for key, st := range c.slots.byKey { | |
| 170 | + | if key.workspace == name && st.window == 0 && !st.pending { | |
| 171 | + | keys = append(keys, key) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index }) | |
| 175 | + | return keys | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "slices" | |||
| 6 | + | "strconv" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves | |||
| 12 | + | // column N for the window of one command. The slot, not the window, has the | |||
| 13 | + | // identity: nefertty spawns the command with SlotEnv set to a token, and the | |||
| 14 | + | // first window of a client carrying that token fills the slot. | |||
| 15 | + | // | |||
| 16 | + | // A slot is spawned at startup and when its workspace is shown while it is | |||
| 17 | + | // empty and no spawn is pending. Nothing is relaunched on its own. | |||
| 18 | + | ||||
| 19 | + | type slotKey struct { | |||
| 20 | + | workspace string | |||
| 21 | + | index int | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type slotState struct { | |||
| 25 | + | argv []string | |||
| 26 | + | width Width | |||
| 27 | + | token string // SlotEnv value of the last spawn; "" before the first | |||
| 28 | + | window WindowID // 0 while empty | |||
| 29 | + | pending bool // spawned, window not mapped yet | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type slots struct { | |||
| 33 | + | byKey map[slotKey]*slotState | |||
| 34 | + | // byToken maps spawn tokens (current and past) to their slot, so a | |||
| 35 | + | // late window of an old spawn still lands in its slot. | |||
| 36 | + | byToken map[string]slotKey | |||
| 37 | + | next uint64 | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func newSlots() slots { | |||
| 41 | + | return slots{byKey: map[slotKey]*slotState{}, byToken: map[string]slotKey{}} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // slotSpec is one parsed slot of the config. | |||
| 45 | + | type slotSpec struct { | |||
| 46 | + | key slotKey | |||
| 47 | + | argv []string | |||
| 48 | + | width Width | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) { | |||
| 52 | + | var specs []slotSpec | |||
| 53 | + | for _, w := range ws { | |||
| 54 | + | for _, s := range w.Slots { | |||
| 55 | + | width, err := ParseWidth(s.Width) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err) | |||
| 58 | + | } | |||
| 59 | + | if len(s.Argv) == 0 || s.Index < 1 { | |||
| 60 | + | return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index) | |||
| 61 | + | } | |||
| 62 | + | specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width}) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return specs, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // updateSlots applies the config. It returns the new slots, which the caller | |||
| 69 | + | // spawns; changed commands are respawned only when their slot is empty. | |||
| 70 | + | // A slot dropped from config keeps its window as a normal column. | |||
| 71 | + | func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 72 | + | want := map[slotKey]slotSpec{} | |||
| 73 | + | for _, s := range specs { | |||
| 74 | + | want[s.key] = s | |||
| 75 | + | } | |||
| 76 | + | for key := range c.slots.byKey { | |||
| 77 | + | if _, ok := want[key]; ok { | |||
| 78 | + | continue | |||
| 79 | + | } | |||
| 80 | + | if w := c.ws.byName(key.workspace); w != nil { | |||
| 81 | + | w.unslot(key.index) | |||
| 82 | + | } | |||
| 83 | + | c.forgetTokens(key) | |||
| 84 | + | delete(c.slots.byKey, key) | |||
| 85 | + | } | |||
| 86 | + | var spawn []slotKey | |||
| 87 | + | for _, s := range specs { | |||
| 88 | + | st, ok := c.slots.byKey[s.key] | |||
| 89 | + | if !ok { | |||
| 90 | + | c.slots.byKey[s.key] = &slotState{argv: s.argv, width: s.width} | |||
| 91 | + | spawn = append(spawn, s.key) | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | if st.width != s.width { | |||
| 95 | + | st.width = s.width | |||
| 96 | + | if w := c.ws.byName(s.key.workspace); w != nil { | |||
| 97 | + | w.setSlotWidth(s.key.index, s.width) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | if !slices.Equal(st.argv, s.argv) { | |||
| 101 | + | st.argv = s.argv | |||
| 102 | + | if st.window == 0 { | |||
| 103 | + | // A pending spawn ran the old command: its window is no | |||
| 104 | + | // longer the slot's; the new command replaces it. | |||
| 105 | + | c.forgetTokens(s.key) | |||
| 106 | + | st.pending = false | |||
| 107 | + | spawn = append(spawn, s.key) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | return spawn | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // forgetTokens drops the spawn tokens of a slot: their windows open as normal ones. | |||
| 115 | + | func (c *Core) forgetTokens(key slotKey) { | |||
| 116 | + | for tok, k := range c.slots.byToken { | |||
| 117 | + | if k == key { | |||
| 118 | + | delete(c.slots.byToken, tok) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // spawnSlot starts the slot's command with a fresh token. | |||
| 124 | + | func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest { | |||
| 125 | + | st := c.slots.byKey[key] | |||
| 126 | + | c.slots.next++ | |||
| 127 | + | st.token = strconv.FormatUint(c.slots.next, 10) | |||
| 128 | + | st.pending = true | |||
| 129 | + | c.slots.byToken[st.token] = key | |||
| 130 | + | return ports.SpawnRequest{Argv: slices.Clone(st.argv), Env: []string{ports.SlotEnv + "=" + st.token}} | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // placeSlotWindow puts a mapped window with a slot token into its slot. It | |||
| 134 | + | // returns false when the window is not a slot window (unknown token, slot | |||
| 135 | + | // already filled, or its workspace is gone): it then follows normal rules. | |||
| 136 | + | func (c *Core) placeSlotWindow(id WindowID, token string) bool { | |||
| 137 | + | key, ok := c.slots.byToken[token] | |||
| 138 | + | if !ok { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | st := c.slots.byKey[key] | |||
| 142 | + | w := c.ws.byName(key.workspace) | |||
| 143 | + | if st == nil || st.window != 0 || w == nil { | |||
| 144 | + | return false | |||
| 145 | + | } | |||
| 146 | + | st.window, st.pending = id, false | |||
| 147 | + | w.AddSlotWindow(id, key.index, st.width) | |||
| 148 | + | c.ws.normalize() | |||
| 149 | + | return true | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // slotWindowGone empties the slot of a closed window. The slot stays | |||
| 153 | + | // reserved; it is refilled when the user next shows its workspace. | |||
| 154 | + | func (c *Core) slotWindowGone(id WindowID) { | |||
| 155 | + | for _, st := range c.slots.byKey { | |||
| 156 | + | if st.window == id { | |||
| 157 | + | st.window = 0 | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // refill returns the empty, idle slots of the workspace on screen. | |||
| 163 | + | func (c *Core) refill() []slotKey { | |||
| 164 | + | name := c.ws.Current().Name | |||
| 165 | + | if name == "" { | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | var keys []slotKey | |||
| 169 | + | for key, st := range c.slots.byKey { | |||
| 170 | + | if key.workspace == name && st.window == 0 && !st.pending { | |||
| 171 | + | keys = append(keys, key) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index }) | |||
| 175 | + | return keys | |||
| 176 | + | } | |||
A internal/core/slots_test.go +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 11 | + | "github.com/bnema/nefertty/internal/core" | |
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type slotRig struct { | |
| 16 | + | client chan ports.ClientEvent | |
| 17 | + | input chan ports.InputEvent | |
| 18 | + | reload chan ports.ConfigChanged | |
| 19 | + | spawn chan ports.SpawnRequest | |
| 20 | + | scenes chan ports.Scene | |
| 21 | + | errs chan error | |
| 22 | + | cfg ports.Config | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | |
| 26 | + | // by Alt+d, on a 100x80 output. | |
| 27 | + | func startSlots(t *testing.T) *slotRig { | |
| 28 | + | t.Helper() | |
| 29 | + | cfg := config.Defaults() | |
| 30 | + | cfg.Keyboard.CmdKey = "alt" | |
| 31 | + | cfg.Border.Width = 0 | |
| 32 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{ | |
| 33 | + | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |
| 34 | + | {Index: 2, Width: "30%", Argv: []string{"foot"}}, | |
| 35 | + | }}} | |
| 36 | + | cfg.Binds["Alt+d"] = "workspace dev" | |
| 37 | + | r := &slotRig{ | |
| 38 | + | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 39 | + | reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16), | |
| 40 | + | scenes: make(chan ports.Scene, 1), errs: make(chan error, 4), cfg: cfg, | |
| 41 | + | } | |
| 42 | + | output := make(chan ports.OutputEvent, 1) | |
| 43 | + | commands := make(chan ports.ClientCommand, 1024) | |
| 44 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs}) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 49 | + | done := make(chan error, 1) | |
| 50 | + | go func() { done <- c.Run(ctx) }() | |
| 51 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 52 | + | go func() { // commands are not under test | |
| 53 | + | for range commands { | |
| 54 | + | } | |
| 55 | + | }() | |
| 56 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 57 | + | receive(t, r.scenes) | |
| 58 | + | return r | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // token returns the SlotEnv value of a spawn request. | |
| 62 | + | func token(t *testing.T, req ports.SpawnRequest) string { | |
| 63 | + | t.Helper() | |
| 64 | + | for _, e := range req.Env { | |
| 65 | + | if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok { | |
| 66 | + | return v | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | t.Fatalf("no %s in %v", ports.SlotEnv, req.Env) | |
| 70 | + | return "" | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) { | |
| 74 | + | t.Helper() | |
| 75 | + | select { | |
| 76 | + | case v := <-ch: | |
| 77 | + | t.Fatalf("unexpected spawn %v", v.Argv) | |
| 78 | + | case <-time.After(50 * time.Millisecond): | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // scene waits for a scene matching ok. | |
| 83 | + | func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene { | |
| 84 | + | t.Helper() | |
| 85 | + | deadline := time.After(time.Second) | |
| 86 | + | for { | |
| 87 | + | select { | |
| 88 | + | case s := <-ch: | |
| 89 | + | if ok(s) { | |
| 90 | + | return s | |
| 91 | + | } | |
| 92 | + | case <-deadline: | |
| 93 | + | t.Fatal("no matching scene") | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func visible(s ports.Scene) map[ports.WindowID]ports.Rect { | |
| 99 | + | m := map[ports.WindowID]ports.Rect{} | |
| 100 | + | for _, w := range s.Windows { | |
| 101 | + | if !w.Hidden { | |
| 102 | + | m[w.ID] = w.Rect | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return m | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |
| 109 | + | r := startSlots(t) | |
| 110 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 111 | + | if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) { | |
| 112 | + | t.Fatal(code, foot) | |
| 113 | + | } | |
| 114 | + | // A normal window on workspace 1 keeps focus while slot windows map. | |
| 115 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 116 | + | // foot maps first; code's window still goes left of it. | |
| 117 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |
| 118 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 119 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |
| 120 | + | for _, w := range s.Windows { | |
| 121 | + | if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused { | |
| 122 | + | t.Fatalf("slot window on screen or focus moved: %+v", s.Windows) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 126 | + | s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | |
| 127 | + | got := visible(s) | |
| 128 | + | if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 { | |
| 129 | + | t.Fatal(got) | |
| 130 | + | } | |
| 131 | + | noSpawn(t, r.spawn) // full slots are not respawned | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |
| 135 | + | r := startSlots(t) | |
| 136 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 137 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 138 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |
| 139 | + | r.client <- ports.WindowUnmapped{ID: 3} // foot exits | |
| 140 | + | scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| 141 | + | noSpawn(t, r.spawn) // nothing relaunches on its own | |
| 142 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 143 | + | again := receive(t, r.spawn) | |
| 144 | + | if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) { | |
| 145 | + | t.Fatal(again) | |
| 146 | + | } | |
| 147 | + | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | |
| 148 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | |
| 149 | + | if got := visible(s); got[4].X != 70 { | |
| 150 | + | t.Fatal(got) | |
| 151 | + | } | |
| 152 | + | // Leaving and showing dev again with full slots spawns nothing. | |
| 153 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 154 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 155 | + | noSpawn(t, r.spawn) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestSlotExtraWindowsAreNormal(t *testing.T) { | |
| 159 | + | r := startSlots(t) | |
| 160 | + | code := receive(t, r.spawn) | |
| 161 | + | receive(t, r.spawn) | |
| 162 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 163 | + | // A second window of the same process: the slot is taken, normal rules. | |
| 164 | + | r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)} | |
| 165 | + | r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"} | |
| 166 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |
| 167 | + | if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) { | |
| 168 | + | t.Fatalf("extra windows not on the workspace on screen: %v", got) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestSlotReload(t *testing.T) { | |
| 173 | + | r := startSlots(t) | |
| 174 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 175 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 176 | + | scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |
| 177 | + | cfg := r.cfg | |
| 178 | + | ws := cfg.Workspaces[0] | |
| 179 | + | ws.Slots = []ports.SlotConfig{ | |
| 180 | + | {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only | |
| 181 | + | {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command | |
| 182 | + | {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot | |
| 183 | + | } | |
| 184 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 185 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 186 | + | var argv []string | |
| 187 | + | for range 2 { | |
| 188 | + | argv = append(argv, receive(t, r.spawn).Argv[0]) | |
| 189 | + | } | |
| 190 | + | slices.Sort(argv) | |
| 191 | + | if !slices.Equal(argv, []string{"btop", "kitty"}) { | |
| 192 | + | t.Fatal(argv) | |
| 193 | + | } | |
| 194 | + | noSpawn(t, r.spawn) | |
| 195 | + | // The old foot spawn no longer fills slot 2 (its command changed): it | |
| 196 | + | // opens as a normal window on workspace 1, on screen. | |
| 197 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |
| 198 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |
| 199 | + | if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) { | |
| 200 | + | t.Fatal(got) | |
| 201 | + | } | |
| 202 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 203 | + | s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 }) | |
| 204 | + | if got := visible(s); got[2].W != 50 { | |
| 205 | + | t.Fatal(got) | |
| 206 | + | } | |
| 207 | + | // Dropping the workspace's slots spawns nothing and keeps the window. | |
| 208 | + | ws.Slots = nil | |
| 209 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 210 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 211 | + | noSpawn(t, r.spawn) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestInvalidSlotWidthRejected(t *testing.T) { | |
| 215 | + | r := startSlots(t) | |
| 216 | + | receive(t, r.spawn) | |
| 217 | + | receive(t, r.spawn) | |
| 218 | + | cfg := r.cfg | |
| 219 | + | ws := cfg.Workspaces[0] | |
| 220 | + | ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}} | |
| 221 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 222 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 223 | + | if err := receive(t, r.errs); err == nil { | |
| 224 | + | t.Fatal("no error") | |
| 225 | + | } | |
| 226 | + | noSpawn(t, r.spawn) | |
| 227 | + | } | |
| @@ -0,0 +1,227 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 11 | + | "github.com/bnema/nefertty/internal/core" | |||
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type slotRig struct { | |||
| 16 | + | client chan ports.ClientEvent | |||
| 17 | + | input chan ports.InputEvent | |||
| 18 | + | reload chan ports.ConfigChanged | |||
| 19 | + | spawn chan ports.SpawnRequest | |||
| 20 | + | scenes chan ports.Scene | |||
| 21 | + | errs chan error | |||
| 22 | + | cfg ports.Config | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // startSlots runs core with a hidden "dev" workspace of two slots, toggled | |||
| 26 | + | // by Alt+d, on a 100x80 output. | |||
| 27 | + | func startSlots(t *testing.T) *slotRig { | |||
| 28 | + | t.Helper() | |||
| 29 | + | cfg := config.Defaults() | |||
| 30 | + | cfg.Keyboard.CmdKey = "alt" | |||
| 31 | + | cfg.Border.Width = 0 | |||
| 32 | + | cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: true, Slots: []ports.SlotConfig{ | |||
| 33 | + | {Index: 1, Width: "70%", Argv: []string{"code"}}, | |||
| 34 | + | {Index: 2, Width: "30%", Argv: []string{"foot"}}, | |||
| 35 | + | }}} | |||
| 36 | + | cfg.Binds["Alt+d"] = "workspace dev" | |||
| 37 | + | r := &slotRig{ | |||
| 38 | + | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |||
| 39 | + | reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16), | |||
| 40 | + | scenes: make(chan ports.Scene, 1), errs: make(chan error, 4), cfg: cfg, | |||
| 41 | + | } | |||
| 42 | + | output := make(chan ports.OutputEvent, 1) | |||
| 43 | + | commands := make(chan ports.ClientCommand, 1024) | |||
| 44 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, ConfigErrors: r.errs}) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 49 | + | done := make(chan error, 1) | |||
| 50 | + | go func() { done <- c.Run(ctx) }() | |||
| 51 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 52 | + | go func() { // commands are not under test | |||
| 53 | + | for range commands { | |||
| 54 | + | } | |||
| 55 | + | }() | |||
| 56 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 57 | + | receive(t, r.scenes) | |||
| 58 | + | return r | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // token returns the SlotEnv value of a spawn request. | |||
| 62 | + | func token(t *testing.T, req ports.SpawnRequest) string { | |||
| 63 | + | t.Helper() | |||
| 64 | + | for _, e := range req.Env { | |||
| 65 | + | if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok { | |||
| 66 | + | return v | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | t.Fatalf("no %s in %v", ports.SlotEnv, req.Env) | |||
| 70 | + | return "" | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) { | |||
| 74 | + | t.Helper() | |||
| 75 | + | select { | |||
| 76 | + | case v := <-ch: | |||
| 77 | + | t.Fatalf("unexpected spawn %v", v.Argv) | |||
| 78 | + | case <-time.After(50 * time.Millisecond): | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // scene waits for a scene matching ok. | |||
| 83 | + | func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene { | |||
| 84 | + | t.Helper() | |||
| 85 | + | deadline := time.After(time.Second) | |||
| 86 | + | for { | |||
| 87 | + | select { | |||
| 88 | + | case s := <-ch: | |||
| 89 | + | if ok(s) { | |||
| 90 | + | return s | |||
| 91 | + | } | |||
| 92 | + | case <-deadline: | |||
| 93 | + | t.Fatal("no matching scene") | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func visible(s ports.Scene) map[ports.WindowID]ports.Rect { | |||
| 99 | + | m := map[ports.WindowID]ports.Rect{} | |||
| 100 | + | for _, w := range s.Windows { | |||
| 101 | + | if !w.Hidden { | |||
| 102 | + | m[w.ID] = w.Rect | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | return m | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |||
| 109 | + | r := startSlots(t) | |||
| 110 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |||
| 111 | + | if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) { | |||
| 112 | + | t.Fatal(code, foot) | |||
| 113 | + | } | |||
| 114 | + | // A normal window on workspace 1 keeps focus while slot windows map. | |||
| 115 | + | r.client <- ports.WindowMapped{ID: 1} | |||
| 116 | + | // foot maps first; code's window still goes left of it. | |||
| 117 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |||
| 118 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |||
| 119 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |||
| 120 | + | for _, w := range s.Windows { | |||
| 121 | + | if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused { | |||
| 122 | + | t.Fatalf("slot window on screen or focus moved: %+v", s.Windows) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |||
| 126 | + | s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | |||
| 127 | + | got := visible(s) | |||
| 128 | + | if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 { | |||
| 129 | + | t.Fatal(got) | |||
| 130 | + | } | |||
| 131 | + | noSpawn(t, r.spawn) // full slots are not respawned | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||
| 135 | + | r := startSlots(t) | |||
| 136 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |||
| 137 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |||
| 138 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |||
| 139 | + | r.client <- ports.WindowUnmapped{ID: 3} // foot exits | |||
| 140 | + | scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |||
| 141 | + | noSpawn(t, r.spawn) // nothing relaunches on its own | |||
| 142 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |||
| 143 | + | again := receive(t, r.spawn) | |||
| 144 | + | if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) { | |||
| 145 | + | t.Fatal(again) | |||
| 146 | + | } | |||
| 147 | + | r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)} | |||
| 148 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 }) | |||
| 149 | + | if got := visible(s); got[4].X != 70 { | |||
| 150 | + | t.Fatal(got) | |||
| 151 | + | } | |||
| 152 | + | // Leaving and showing dev again with full slots spawns nothing. | |||
| 153 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |||
| 154 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |||
| 155 | + | noSpawn(t, r.spawn) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestSlotExtraWindowsAreNormal(t *testing.T) { | |||
| 159 | + | r := startSlots(t) | |||
| 160 | + | code := receive(t, r.spawn) | |||
| 161 | + | receive(t, r.spawn) | |||
| 162 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |||
| 163 | + | // A second window of the same process: the slot is taken, normal rules. | |||
| 164 | + | r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)} | |||
| 165 | + | r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"} | |||
| 166 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 }) | |||
| 167 | + | if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) { | |||
| 168 | + | t.Fatalf("extra windows not on the workspace on screen: %v", got) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestSlotReload(t *testing.T) { | |||
| 173 | + | r := startSlots(t) | |||
| 174 | + | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |||
| 175 | + | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |||
| 176 | + | scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 }) | |||
| 177 | + | cfg := r.cfg | |||
| 178 | + | ws := cfg.Workspaces[0] | |||
| 179 | + | ws.Slots = []ports.SlotConfig{ | |||
| 180 | + | {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only | |||
| 181 | + | {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command | |||
| 182 | + | {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot | |||
| 183 | + | } | |||
| 184 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |||
| 185 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 186 | + | var argv []string | |||
| 187 | + | for range 2 { | |||
| 188 | + | argv = append(argv, receive(t, r.spawn).Argv[0]) | |||
| 189 | + | } | |||
| 190 | + | slices.Sort(argv) | |||
| 191 | + | if !slices.Equal(argv, []string{"btop", "kitty"}) { | |||
| 192 | + | t.Fatal(argv) | |||
| 193 | + | } | |||
| 194 | + | noSpawn(t, r.spawn) | |||
| 195 | + | // The old foot spawn no longer fills slot 2 (its command changed): it | |||
| 196 | + | // opens as a normal window on workspace 1, on screen. | |||
| 197 | + | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |||
| 198 | + | s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 }) | |||
| 199 | + | if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) { | |||
| 200 | + | t.Fatal(got) | |||
| 201 | + | } | |||
| 202 | + | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |||
| 203 | + | s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 }) | |||
| 204 | + | if got := visible(s); got[2].W != 50 { | |||
| 205 | + | t.Fatal(got) | |||
| 206 | + | } | |||
| 207 | + | // Dropping the workspace's slots spawns nothing and keeps the window. | |||
| 208 | + | ws.Slots = nil | |||
| 209 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |||
| 210 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 211 | + | noSpawn(t, r.spawn) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestInvalidSlotWidthRejected(t *testing.T) { | |||
| 215 | + | r := startSlots(t) | |||
| 216 | + | receive(t, r.spawn) | |||
| 217 | + | receive(t, r.spawn) | |||
| 218 | + | cfg := r.cfg | |||
| 219 | + | ws := cfg.Workspaces[0] | |||
| 220 | + | ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}} | |||
| 221 | + | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |||
| 222 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 223 | + | if err := receive(t, r.errs); err == nil { | |||
| 224 | + | t.Fatal("no error") | |||
| 225 | + | } | |||
| 226 | + | noSpawn(t, r.spawn) | |||
| 227 | + | } | |||
M internal/ports/config.go +12 -1
| @@ -59,8 +59,19 @@ type Config struct { | |||
| 59 | 59 | // only reachable through a `workspace <name>` bind, which toggles them. | |
| 60 | 60 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 61 | 61 | type WorkspaceConfig struct { | |
| 62 | - | Name string | |
| 62 | + | Name string | |
| 63 | + | // Slots are the declared columns (workspace.<name>.column.N), by N. | |
| 64 | + | Slots []SlotConfig | |
| 63 | 65 | Hidden bool | |
| 64 | 66 | MaxColumns int | |
| 65 | 67 | 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 | + | } | |
| @@ -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 string | 62 | + | Name string |
| 63 | + | // Slots are the declared columns (workspace.<name>.column.N), by N. | |||
| 64 | + | Slots []SlotConfig | |||
| 63 | Hidden bool | 65 | Hidden bool | ||
| 64 | MaxColumns int | 66 | MaxColumns int | ||
| 65 | Overflow string | 67 | Overflow string | ||
| 66 | } | 68 | } | ||
| 69 | + | ||||
| 70 | + | // SlotConfig reserves column N of a workspace for the window of one command. | |||
| 71 | + | // nefertty spawns the command at startup; the window it opens goes to the | |||
| 72 | + | // slot. Width is a layout width (fraction, percentage or pixels). | |||
| 73 | + | type SlotConfig struct { | |||
| 74 | + | Index int | |||
| 75 | + | Width string | |||
| 76 | + | Argv []string | |||
| 77 | + | } | |||
M internal/ports/messages.go +11 -1
| @@ -7,8 +7,14 @@ type ClientEvent interface{ clientEvent() } | |||
| 7 | 7 | type WindowMapped struct { | |
| 8 | 8 | ID WindowID | |
| 9 | 9 | 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 | 18 | func (WindowMapped) clientEvent() {} | |
| 13 | 19 | ||
| 14 | 20 | // WindowUnmapped carries wayland → core removal. | |
| @@ -214,7 +220,11 @@ type CursorImage struct { | |||
| 214 | 220 | } | |
| 215 | 221 | ||
| 216 | 222 | // SpawnRequest carries core → launcher process arguments. | |
| 217 | - | type SpawnRequest struct{ Argv []string } | |
| 223 | + | type SpawnRequest struct { | |
| 224 | + | Argv []string | |
| 225 | + | // Env adds KEY=value entries to the child environment. | |
| 226 | + | Env []string | |
| 227 | + | } | |
| 218 | 228 | ||
| 219 | 229 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | |
| 220 | 230 | // 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 WindowID | 8 | ID WindowID | ||
| 9 | AppID string | 9 | AppID string | ||
| 10 | + | // Slot is the SlotEnv value of the client process, empty if none. | |||
| 11 | + | Slot string | |||
| 10 | } | 12 | } | ||
| 11 | 13 | ||||
| 14 | + | // SlotEnv is the environment variable nefertty sets on processes it spawns | |||
| 15 | + | // for a slot. The client's value maps its windows to the slot. | |||
| 16 | + | const SlotEnv = "NEFERTTY_SLOT" | |||
| 17 | + | ||||
| 12 | func (WindowMapped) clientEvent() {} | 18 | func (WindowMapped) clientEvent() {} | ||
| 13 | 19 | ||||
| 14 | // WindowUnmapped carries wayland → core removal. | 20 | // WindowUnmapped carries wayland → core removal. | ||
| @@ -214,7 +220,11 @@ type CursorImage struct { | |||||
| 214 | } | 220 | } | ||
| 215 | 221 | ||||
| 216 | // SpawnRequest carries core → launcher process arguments. | 222 | // SpawnRequest carries core → launcher process arguments. | ||
| 217 | - | type SpawnRequest struct{ Argv []string } | 223 | + | type SpawnRequest struct { |
| 224 | + | Argv []string | |||
| 225 | + | // Env adds KEY=value entries to the child environment. | |||
| 226 | + | Env []string | |||
| 227 | + | } | |||
| 218 | 228 | ||||
| 219 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | 229 | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | ||
| 220 | // Rects and the output size are logical; the renderer multiplies by Scale. | 230 | // Rects and the output size are logical; the renderer multiplies by Scale. | ||