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