feat(core): add core owner goroutine with message ports and keybinding resolution
- commit
- cd738d1a965555836fed6bf72c93f4640c6c7ee4
- author
- brice · · 2026-09-23 11:45 UTC
- parent
- 0a610cc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +783 -16
M .hexcheck.yaml +8 -0
| @@ -11,3 +11,11 @@ rules: | |||
| 11 | 11 | no-infra-imports-in-usecase: error | |
| 12 | 12 | no-framework-types-in-core: error | |
| 13 | 13 | no-infra-types-in-ports: error | |
| 14 | + | missing-generated-mock-for-port: error | |
| 15 | + | mocking: | |
| 16 | + | generatedMockPaths: [internal/mocks/**] | |
| 17 | + | generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock'] | |
| 18 | + | ruleSettings: | |
| 19 | + | missing-generated-mock-for-port: | |
| 20 | + | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | |
| 21 | + | excludePaths: [internal/ports/messages.go] | |
| @@ -11,3 +11,11 @@ rules: | |||||
| 11 | no-infra-imports-in-usecase: error | 11 | no-infra-imports-in-usecase: error | ||
| 12 | no-framework-types-in-core: error | 12 | no-framework-types-in-core: error | ||
| 13 | no-infra-types-in-ports: error | 13 | no-infra-types-in-ports: error | ||
| 14 | + | missing-generated-mock-for-port: error | |||
| 15 | + | mocking: | |||
| 16 | + | generatedMockPaths: [internal/mocks/**] | |||
| 17 | + | generatedMockNamePatterns: ['Mock{{Interface}}', '{{Interface}}Mock'] | |||
| 18 | + | ruleSettings: | |||
| 19 | + | missing-generated-mock-for-port: | |||
| 20 | + | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | |||
| 21 | + | excludePaths: [internal/ports/messages.go] | |||
M .mockery.yml +2 -2
| @@ -1,9 +1,9 @@ | |||
| 1 | - | # No interfaces exist yet; Mockery v3 treats this package as a clean no-op. | |
| 1 | + | # Sealed message interfaces are not mockable; channel ports are tested with messages. | |
| 2 | 2 | all: false | |
| 3 | 3 | packages: | |
| 4 | 4 | github.com/bnema/nefertty/internal/ports: | |
| 5 | 5 | config: | |
| 6 | - | all: true | |
| 6 | + | all: false | |
| 7 | 7 | dir: internal/mocks/{{.SrcPackageName}} | |
| 8 | 8 | pkgname: '{{.SrcPackageName}}' | |
| 9 | 9 | filename: '{{.InterfaceName}}.go' | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | - | # No interfaces exist yet; Mockery v3 treats this package as a clean no-op. | 1 | + | # Sealed message interfaces are not mockable; channel ports are tested with messages. |
| 2 | all: false | 2 | all: false | ||
| 3 | packages: | 3 | packages: | ||
| 4 | github.com/bnema/nefertty/internal/ports: | 4 | github.com/bnema/nefertty/internal/ports: | ||
| 5 | config: | 5 | config: | ||
| 6 | - | all: true | 6 | + | all: false |
| 7 | dir: internal/mocks/{{.SrcPackageName}} | 7 | dir: internal/mocks/{{.SrcPackageName}} | ||
| 8 | pkgname: '{{.SrcPackageName}}' | 8 | pkgname: '{{.SrcPackageName}}' | ||
| 9 | filename: '{{.InterfaceName}}.go' | 9 | filename: '{{.InterfaceName}}.go' | ||
M internal/adapters/config/config.go +39 -5
| @@ -134,6 +134,36 @@ func normalize(s string) (string, error) { | |||
| 134 | 134 | sort.Strings(mods) | |
| 135 | 135 | return strings.Join(append(mods, p[len(p)-1]), "+"), nil | |
| 136 | 136 | } | |
| 137 | + | ||
| 138 | + | // resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide. | |
| 139 | + | func resolvedCombo(s, cmd string) (string, error) { | |
| 140 | + | parts := strings.Split(s, "+") | |
| 141 | + | mods := map[string]bool{} | |
| 142 | + | for _, m := range parts[:len(parts)-1] { | |
| 143 | + | if m == "Cmd" { | |
| 144 | + | switch cmd { | |
| 145 | + | case "super": | |
| 146 | + | m = "Super" | |
| 147 | + | case "alt": | |
| 148 | + | m = "Alt" | |
| 149 | + | case "ctrl": | |
| 150 | + | m = "Ctrl" | |
| 151 | + | default: | |
| 152 | + | return "", fmt.Errorf("invalid cmd key") | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | if mods[m] { | |
| 156 | + | return "", fmt.Errorf("duplicate resolved modifier") | |
| 157 | + | } | |
| 158 | + | mods[m] = true | |
| 159 | + | } | |
| 160 | + | ordered := make([]string, 0, len(mods)) | |
| 161 | + | for m := range mods { | |
| 162 | + | ordered = append(ordered, m) | |
| 163 | + | } | |
| 164 | + | sort.Strings(ordered) | |
| 165 | + | return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil | |
| 166 | + | } | |
| 137 | 167 | func Validate(c ports.Config) error { | |
| 138 | 168 | var errs []error | |
| 139 | 169 | add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) } | |
| @@ -189,13 +219,17 @@ func Validate(c ports.Config) error { | |||
| 189 | 219 | combos := map[string]string{} | |
| 190 | 220 | for _, k := range keys { | |
| 191 | 221 | v := c.Binds[k] | |
| 192 | - | n, e := normalize(k) | |
| 193 | - | if e != nil { | |
| 222 | + | if _, e := normalize(k); e != nil { | |
| 194 | 223 | add("binds."+k, e.Error()) | |
| 195 | - | } else if old, ok := combos[n]; ok { | |
| 196 | - | add("binds."+k, "duplicates "+old) | |
| 197 | 224 | } else { | |
| 198 | - | combos[n] = k | |
| 225 | + | n, e := resolvedCombo(k, c.Keyboard.CmdKey) | |
| 226 | + | if e != nil { | |
| 227 | + | add("binds."+k, e.Error()) | |
| 228 | + | } else if old, ok := combos[n]; ok { | |
| 229 | + | add("binds."+k, "duplicates "+old) | |
| 230 | + | } else { | |
| 231 | + | combos[n] = k | |
| 232 | + | } | |
| 199 | 233 | } | |
| 200 | 234 | if !actions[v] { | |
| 201 | 235 | add("binds."+k, "invalid action") | |
| @@ -134,6 +134,36 @@ func normalize(s string) (string, error) { | |||||
| 134 | sort.Strings(mods) | 134 | sort.Strings(mods) | ||
| 135 | return strings.Join(append(mods, p[len(p)-1]), "+"), nil | 135 | return strings.Join(append(mods, p[len(p)-1]), "+"), nil | ||
| 136 | } | 136 | } | ||
| 137 | + | ||||
| 138 | + | // resolvedCombo canonicalizes modifiers after resolving Cmd, so aliases collide. | |||
| 139 | + | func resolvedCombo(s, cmd string) (string, error) { | |||
| 140 | + | parts := strings.Split(s, "+") | |||
| 141 | + | mods := map[string]bool{} | |||
| 142 | + | for _, m := range parts[:len(parts)-1] { | |||
| 143 | + | if m == "Cmd" { | |||
| 144 | + | switch cmd { | |||
| 145 | + | case "super": | |||
| 146 | + | m = "Super" | |||
| 147 | + | case "alt": | |||
| 148 | + | m = "Alt" | |||
| 149 | + | case "ctrl": | |||
| 150 | + | m = "Ctrl" | |||
| 151 | + | default: | |||
| 152 | + | return "", fmt.Errorf("invalid cmd key") | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | if mods[m] { | |||
| 156 | + | return "", fmt.Errorf("duplicate resolved modifier") | |||
| 157 | + | } | |||
| 158 | + | mods[m] = true | |||
| 159 | + | } | |||
| 160 | + | ordered := make([]string, 0, len(mods)) | |||
| 161 | + | for m := range mods { | |||
| 162 | + | ordered = append(ordered, m) | |||
| 163 | + | } | |||
| 164 | + | sort.Strings(ordered) | |||
| 165 | + | return strings.Join(append(ordered, parts[len(parts)-1]), "+"), nil | |||
| 166 | + | } | |||
| 137 | func Validate(c ports.Config) error { | 167 | func Validate(c ports.Config) error { | ||
| 138 | var errs []error | 168 | var errs []error | ||
| 139 | add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) } | 169 | add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) } | ||
| @@ -189,13 +219,17 @@ func Validate(c ports.Config) error { | |||||
| 189 | combos := map[string]string{} | 219 | combos := map[string]string{} | ||
| 190 | for _, k := range keys { | 220 | for _, k := range keys { | ||
| 191 | v := c.Binds[k] | 221 | v := c.Binds[k] | ||
| 192 | - | n, e := normalize(k) | 222 | + | if _, e := normalize(k); e != nil { |
| 193 | - | if e != nil { | |||
| 194 | add("binds."+k, e.Error()) | 223 | add("binds."+k, e.Error()) | ||
| 195 | - | } else if old, ok := combos[n]; ok { | |||
| 196 | - | add("binds."+k, "duplicates "+old) | |||
| 197 | } else { | 224 | } else { | ||
| 198 | - | combos[n] = k | 225 | + | n, e := resolvedCombo(k, c.Keyboard.CmdKey) |
| 226 | + | if e != nil { | |||
| 227 | + | add("binds."+k, e.Error()) | |||
| 228 | + | } else if old, ok := combos[n]; ok { | |||
| 229 | + | add("binds."+k, "duplicates "+old) | |||
| 230 | + | } else { | |||
| 231 | + | combos[n] = k | |||
| 232 | + | } | |||
| 199 | } | 233 | } | ||
| 200 | if !actions[v] { | 234 | if !actions[v] { | ||
| 201 | add("binds."+k, "invalid action") | 235 | add("binds."+k, "invalid action") | ||
M internal/adapters/config/config_test.go +2 -0
| @@ -49,6 +49,8 @@ func TestValidation(t *testing.T) { | |||
| 49 | 49 | {"debug", "[log]\ndebug=['nope']", "log.debug[0]"}, | |
| 50 | 50 | {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"}, | |
| 51 | 51 | {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"}, | |
| 52 | + | {"resolved duplicate", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+X'='quit'\n'Ctrl+X'='quit'", "duplicates"}, | |
| 53 | + | {"duplicate modifier", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+Ctrl+X'='quit'", "duplicate resolved modifier"}, | |
| 52 | 54 | {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"}, | |
| 53 | 55 | {"unknown", "[layout]\nnope=1", "layout.nope"}, | |
| 54 | 56 | } | |
| @@ -49,6 +49,8 @@ func TestValidation(t *testing.T) { | |||||
| 49 | {"debug", "[log]\ndebug=['nope']", "log.debug[0]"}, | 49 | {"debug", "[log]\ndebug=['nope']", "log.debug[0]"}, | ||
| 50 | {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"}, | 50 | {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"}, | ||
| 51 | {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"}, | 51 | {"action", "[binds]\n'Cmd+X'='bogus'", "binds.Cmd+X"}, | ||
| 52 | + | {"resolved duplicate", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+X'='quit'\n'Ctrl+X'='quit'", "duplicates"}, | |||
| 53 | + | {"duplicate modifier", "[keyboard]\ncmd_key='ctrl'\n[binds]\n'Cmd+Ctrl+X'='quit'", "duplicate resolved modifier"}, | |||
| 52 | {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"}, | 54 | {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"}, | ||
| 53 | {"unknown", "[layout]\nnope=1", "layout.nope"}, | 55 | {"unknown", "[layout]\nnope=1", "layout.nope"}, | ||
| 54 | } | 56 | } | ||
M internal/app/app.go +62 -5
| @@ -3,8 +3,11 @@ package app | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 7 | + | "sync" | |
| 6 | 8 | "time" | |
| 7 | 9 | ||
| 10 | + | "github.com/bnema/nefertty/internal/core" | |
| 8 | 11 | "github.com/bnema/nefertty/internal/logging" | |
| 9 | 12 | "github.com/bnema/nefertty/internal/ports" | |
| 10 | 13 | ) | |
| @@ -15,14 +18,68 @@ type Options struct { | |||
| 15 | 18 | Timeout time.Duration | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | - | func Run(ctx context.Context, opts Options) error { | |
| 21 | + | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |
| 22 | + | ||
| 23 | + | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | |
| 19 | 24 | log := logging.For(ctx, "app") | |
| 20 | 25 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |
| 26 | + | var cancel context.CancelFunc | |
| 21 | 27 | if opts.Timeout > 0 { | |
| 22 | - | var cancel context.CancelFunc | |
| 23 | 28 | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | |
| 24 | - | defer cancel() | |
| 29 | + | } else { | |
| 30 | + | ctx, cancel = context.WithCancel(ctx) | |
| 31 | + | } | |
| 32 | + | defer cancel() | |
| 33 | + | client := make(chan ports.ClientEvent, 32) | |
| 34 | + | input := make(chan ports.InputEvent, 32) | |
| 35 | + | output := make(chan ports.OutputEvent, 32) | |
| 36 | + | config := make(chan ports.ConfigChanged, 8) | |
| 37 | + | commands := make(chan ports.ClientCommand, 32) | |
| 38 | + | spawn := make(chan ports.SpawnRequest, 32) | |
| 39 | + | scenes := make(chan ports.Scene, 1) | |
| 40 | + | configErrors := make(chan error, 8) | |
| 41 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | |
| 42 | + | c, err := core.New(opts.Config, ch) | |
| 43 | + | if err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | if opts.Backend == "headless" { | |
| 47 | + | output <- ports.OutputMode{Width: 1920, Height: 1080} | |
| 48 | + | } | |
| 49 | + | if inject != nil { | |
| 50 | + | inject(input) | |
| 51 | + | } | |
| 52 | + | var workers sync.WaitGroup | |
| 53 | + | workers.Add(2) | |
| 54 | + | done := make(chan error, 1) | |
| 55 | + | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 56 | + | go func() { | |
| 57 | + | defer workers.Done() | |
| 58 | + | log := logging.For(ctx, "core") | |
| 59 | + | for { | |
| 60 | + | select { | |
| 61 | + | case <-ctx.Done(): | |
| 62 | + | return | |
| 63 | + | case s := <-scenes: | |
| 64 | + | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | |
| 65 | + | case v := <-commands: | |
| 66 | + | log.Debug().Interface("command", v).Msg("command") | |
| 67 | + | case v := <-spawn: | |
| 68 | + | log.Debug().Interface("spawn", v).Msg("spawn") | |
| 69 | + | case e := <-configErrors: | |
| 70 | + | log.Debug().Err(e).Msg("config rejected") | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | }() | |
| 74 | + | var result error | |
| 75 | + | select { | |
| 76 | + | case <-ctx.Done(): | |
| 77 | + | case result = <-done: | |
| 78 | + | } | |
| 79 | + | cancel() | |
| 80 | + | workers.Wait() | |
| 81 | + | if errors.Is(result, core.ErrQuit) { | |
| 82 | + | return nil | |
| 25 | 83 | } | |
| 26 | - | <-ctx.Done() | |
| 27 | - | return nil | |
| 84 | + | return result | |
| 28 | 85 | } | |
| @@ -3,8 +3,11 @@ package app | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 7 | + | "sync" | |||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| 10 | + | "github.com/bnema/nefertty/internal/core" | |||
| 8 | "github.com/bnema/nefertty/internal/logging" | 11 | "github.com/bnema/nefertty/internal/logging" | ||
| 9 | "github.com/bnema/nefertty/internal/ports" | 12 | "github.com/bnema/nefertty/internal/ports" | ||
| 10 | ) | 13 | ) | ||
| @@ -15,14 +18,68 @@ type Options struct { | |||||
| 15 | Timeout time.Duration | 18 | Timeout time.Duration | ||
| 16 | } | 19 | } | ||
| 17 | 20 | ||||
| 18 | - | func Run(ctx context.Context, opts Options) error { | 21 | + | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } |
| 22 | + | ||||
| 23 | + | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | |||
| 19 | log := logging.For(ctx, "app") | 24 | log := logging.For(ctx, "app") | ||
| 20 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | 25 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | ||
| 26 | + | var cancel context.CancelFunc | |||
| 21 | if opts.Timeout > 0 { | 27 | if opts.Timeout > 0 { | ||
| 22 | - | var cancel context.CancelFunc | |||
| 23 | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | 28 | ctx, cancel = context.WithTimeout(ctx, opts.Timeout) | ||
| 24 | - | defer cancel() | 29 | + | } else { |
| 30 | + | ctx, cancel = context.WithCancel(ctx) | |||
| 31 | + | } | |||
| 32 | + | defer cancel() | |||
| 33 | + | client := make(chan ports.ClientEvent, 32) | |||
| 34 | + | input := make(chan ports.InputEvent, 32) | |||
| 35 | + | output := make(chan ports.OutputEvent, 32) | |||
| 36 | + | config := make(chan ports.ConfigChanged, 8) | |||
| 37 | + | commands := make(chan ports.ClientCommand, 32) | |||
| 38 | + | spawn := make(chan ports.SpawnRequest, 32) | |||
| 39 | + | scenes := make(chan ports.Scene, 1) | |||
| 40 | + | configErrors := make(chan error, 8) | |||
| 41 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | |||
| 42 | + | c, err := core.New(opts.Config, ch) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return err | |||
| 45 | + | } | |||
| 46 | + | if opts.Backend == "headless" { | |||
| 47 | + | output <- ports.OutputMode{Width: 1920, Height: 1080} | |||
| 48 | + | } | |||
| 49 | + | if inject != nil { | |||
| 50 | + | inject(input) | |||
| 51 | + | } | |||
| 52 | + | var workers sync.WaitGroup | |||
| 53 | + | workers.Add(2) | |||
| 54 | + | done := make(chan error, 1) | |||
| 55 | + | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |||
| 56 | + | go func() { | |||
| 57 | + | defer workers.Done() | |||
| 58 | + | log := logging.For(ctx, "core") | |||
| 59 | + | for { | |||
| 60 | + | select { | |||
| 61 | + | case <-ctx.Done(): | |||
| 62 | + | return | |||
| 63 | + | case s := <-scenes: | |||
| 64 | + | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | |||
| 65 | + | case v := <-commands: | |||
| 66 | + | log.Debug().Interface("command", v).Msg("command") | |||
| 67 | + | case v := <-spawn: | |||
| 68 | + | log.Debug().Interface("spawn", v).Msg("spawn") | |||
| 69 | + | case e := <-configErrors: | |||
| 70 | + | log.Debug().Err(e).Msg("config rejected") | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | }() | |||
| 74 | + | var result error | |||
| 75 | + | select { | |||
| 76 | + | case <-ctx.Done(): | |||
| 77 | + | case result = <-done: | |||
| 78 | + | } | |||
| 79 | + | cancel() | |||
| 80 | + | workers.Wait() | |||
| 81 | + | if errors.Is(result, core.ErrQuit) { | |||
| 82 | + | return nil | |||
| 25 | } | 83 | } | ||
| 26 | - | <-ctx.Done() | 84 | + | return result |
| 27 | - | return nil | |||
| 28 | } | 85 | } | ||
A internal/app/app_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestQuitJoinsWorkers(t *testing.T) { | |
| 12 | + | done := make(chan error, 1) | |
| 13 | + | go func() { | |
| 14 | + | done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults()}, func(input chan<- ports.InputEvent) { | |
| 15 | + | input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true} | |
| 16 | + | }) | |
| 17 | + | }() | |
| 18 | + | select { | |
| 19 | + | case err := <-done: | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | case <-time.After(2 * time.Second): | |
| 24 | + | t.Fatal("quit did not join workers") | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestQuitJoinsWorkers(t *testing.T) { | |||
| 12 | + | done := make(chan error, 1) | |||
| 13 | + | go func() { | |||
| 14 | + | done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults()}, func(input chan<- ports.InputEvent) { | |||
| 15 | + | input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true} | |||
| 16 | + | }) | |||
| 17 | + | }() | |||
| 18 | + | select { | |||
| 19 | + | case err := <-done: | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | case <-time.After(2 * time.Second): | |||
| 24 | + | t.Fatal("quit did not join workers") | |||
| 25 | + | } | |||
| 26 | + | } | |||
A internal/core/core.go +293 -0
| @@ -0,0 +1,293 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "strings" | |
| 9 | + | "unicode" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | var ErrQuit = errors.New("quit requested") | |
| 13 | + | ||
| 14 | + | // Channels connects the owner to adapters. scenes must have capacity 1: core is | |
| 15 | + | // its only sender and may drain a stale scene; consumers only receive. | |
| 16 | + | type Channels struct { | |
| 17 | + | Client <-chan ports.ClientEvent | |
| 18 | + | Input <-chan ports.InputEvent | |
| 19 | + | Output <-chan ports.OutputEvent | |
| 20 | + | Config <-chan ports.ConfigChanged | |
| 21 | + | Commands chan<- ports.ClientCommand | |
| 22 | + | Spawn chan<- ports.SpawnRequest | |
| 23 | + | Scenes chan ports.Scene | |
| 24 | + | ConfigErrors chan<- error | |
| 25 | + | } | |
| 26 | + | type binding struct { | |
| 27 | + | mods ports.Mods | |
| 28 | + | key string | |
| 29 | + | } | |
| 30 | + | type Core struct { | |
| 31 | + | ch Channels | |
| 32 | + | cfg ports.Config | |
| 33 | + | ws Workspace | |
| 34 | + | binds map[binding]Action | |
| 35 | + | pressed map[string]bool | |
| 36 | + | sent map[WindowID]ports.ConfigureWindow | |
| 37 | + | focus WindowID | |
| 38 | + | seq uint64 | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func keyName(s string) string { | |
| 42 | + | if len(s) == 1 && unicode.IsLetter(rune(s[0])) { | |
| 43 | + | return strings.ToLower(s) | |
| 44 | + | } | |
| 45 | + | return s | |
| 46 | + | } | |
| 47 | + | func parseBind(s, cmd string) (binding, error) { | |
| 48 | + | parts := strings.Split(s, "+") | |
| 49 | + | b := binding{key: keyName(parts[len(parts)-1])} | |
| 50 | + | if b.key == "" { | |
| 51 | + | return b, fmt.Errorf("invalid bind %q", s) | |
| 52 | + | } | |
| 53 | + | for _, p := range parts[:len(parts)-1] { | |
| 54 | + | var m ports.Mods | |
| 55 | + | switch p { | |
| 56 | + | case "Cmd": | |
| 57 | + | switch cmd { | |
| 58 | + | case "super": | |
| 59 | + | m = ports.ModSuper | |
| 60 | + | case "alt": | |
| 61 | + | m = ports.ModAlt | |
| 62 | + | case "ctrl": | |
| 63 | + | m = ports.ModCtrl | |
| 64 | + | } | |
| 65 | + | case "Shift": | |
| 66 | + | m = ports.ModShift | |
| 67 | + | case "Ctrl": | |
| 68 | + | m = ports.ModCtrl | |
| 69 | + | case "Alt": | |
| 70 | + | m = ports.ModAlt | |
| 71 | + | case "Super": | |
| 72 | + | m = ports.ModSuper | |
| 73 | + | } | |
| 74 | + | if m == 0 || b.mods&m != 0 { | |
| 75 | + | return b, fmt.Errorf("invalid bind %q", s) | |
| 76 | + | } | |
| 77 | + | b.mods |= m | |
| 78 | + | } | |
| 79 | + | return b, nil | |
| 80 | + | } | |
| 81 | + | func (c *Core) apply(cfg ports.Config) error { | |
| 82 | + | width, err := ParseWidth(cfg.Layout.DefaultColumnWidth) | |
| 83 | + | if err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | presets := make([]Width, 0, len(cfg.Layout.Presets)) | |
| 87 | + | for _, s := range cfg.Layout.Presets { | |
| 88 | + | v, e := ParseWidth(s) | |
| 89 | + | if e != nil { | |
| 90 | + | return e | |
| 91 | + | } | |
| 92 | + | presets = append(presets, v) | |
| 93 | + | } | |
| 94 | + | if cfg.Layout.Gaps < 0 { | |
| 95 | + | return fmt.Errorf("invalid gaps") | |
| 96 | + | } | |
| 97 | + | if cfg.Keyboard.CmdKey != "super" && cfg.Keyboard.CmdKey != "alt" && cfg.Keyboard.CmdKey != "ctrl" { | |
| 98 | + | return fmt.Errorf("invalid cmd key") | |
| 99 | + | } | |
| 100 | + | binds := map[binding]Action{} | |
| 101 | + | for combo, a := range cfg.Binds { | |
| 102 | + | b, e := parseBind(combo, cfg.Keyboard.CmdKey) | |
| 103 | + | if e != nil { | |
| 104 | + | return e | |
| 105 | + | } | |
| 106 | + | if _, ok := binds[b]; ok { | |
| 107 | + | return fmt.Errorf("duplicate bind %q", combo) | |
| 108 | + | } | |
| 109 | + | switch Action(a) { | |
| 110 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | |
| 111 | + | default: | |
| 112 | + | return fmt.Errorf("invalid action %q", a) | |
| 113 | + | } | |
| 114 | + | binds[b] = Action(a) | |
| 115 | + | } | |
| 116 | + | c.cfg = cfg | |
| 117 | + | c.binds = binds | |
| 118 | + | c.ws.SetDefaultWidth(width) | |
| 119 | + | c.ws.SetPresets(presets) | |
| 120 | + | c.ws.SetGaps(cfg.Layout.Gaps) | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | func New(cfg ports.Config, ch Channels) (*Core, error) { | |
| 124 | + | if cap(ch.Scenes) != 1 { | |
| 125 | + | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 126 | + | } | |
| 127 | + | c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 128 | + | if err := c.apply(cfg); err != nil { | |
| 129 | + | return nil, err | |
| 130 | + | } | |
| 131 | + | return c, nil | |
| 132 | + | } | |
| 133 | + | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |
| 134 | + | select { | |
| 135 | + | case <-ctx.Done(): | |
| 136 | + | return ctx.Err() | |
| 137 | + | case c.ch.Commands <- v: | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | func (c *Core) publish(ctx context.Context) error { | |
| 142 | + | c.seq++ | |
| 143 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0)} | |
| 144 | + | alive := map[WindowID]bool{} | |
| 145 | + | focus, _ := c.ws.Focused() | |
| 146 | + | for _, p := range c.ws.Layout() { | |
| 147 | + | alive[p.ID] = true | |
| 148 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden}) | |
| 149 | + | if p.Hidden { | |
| 150 | + | if old, ok := c.sent[p.ID]; ok && old.Activated { | |
| 151 | + | old.Activated = false | |
| 152 | + | if err := c.command(ctx, old); err != nil { | |
| 153 | + | return err | |
| 154 | + | } | |
| 155 | + | c.sent[p.ID] = old | |
| 156 | + | } | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused} | |
| 160 | + | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 161 | + | if err := c.command(ctx, v); err != nil { | |
| 162 | + | return err | |
| 163 | + | } | |
| 164 | + | c.sent[p.ID] = v | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | for id := range c.sent { | |
| 168 | + | if !alive[id] { | |
| 169 | + | delete(c.sent, id) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | if focus != c.focus { | |
| 173 | + | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | |
| 174 | + | return err | |
| 175 | + | } | |
| 176 | + | c.focus = focus | |
| 177 | + | } | |
| 178 | + | // Only the owner sends and drains; a renderer only receives. | |
| 179 | + | select { | |
| 180 | + | case c.ch.Scenes <- scene: | |
| 181 | + | default: | |
| 182 | + | select { | |
| 183 | + | case <-c.ch.Scenes: | |
| 184 | + | default: | |
| 185 | + | } | |
| 186 | + | select { | |
| 187 | + | case c.ch.Scenes <- scene: | |
| 188 | + | default: | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | func (c *Core) Run(ctx context.Context) error { | |
| 194 | + | for { | |
| 195 | + | select { | |
| 196 | + | case <-ctx.Done(): | |
| 197 | + | return nil | |
| 198 | + | case ev, ok := <-c.ch.Client: | |
| 199 | + | if !ok { | |
| 200 | + | c.ch.Client = nil | |
| 201 | + | continue | |
| 202 | + | } | |
| 203 | + | switch v := ev.(type) { | |
| 204 | + | case ports.WindowMapped: | |
| 205 | + | c.ws.AddWindow(v.ID) | |
| 206 | + | case ports.WindowUnmapped: | |
| 207 | + | c.ws.RemoveWindow(v.ID) | |
| 208 | + | case ports.WindowFullscreenRequest: | |
| 209 | + | c.ws.SetFullscreen(v.ID, v.Fullscreen) | |
| 210 | + | } | |
| 211 | + | if err := c.publish(ctx); err != nil { | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | case ev, ok := <-c.ch.Output: | |
| 215 | + | if !ok { | |
| 216 | + | c.ch.Output = nil | |
| 217 | + | continue | |
| 218 | + | } | |
| 219 | + | switch v := ev.(type) { | |
| 220 | + | case ports.OutputMode: | |
| 221 | + | c.ws.SetOutput(v.Width, v.Height) | |
| 222 | + | case ports.OutputUsable: | |
| 223 | + | c.ws.SetUsable(v.Rect) | |
| 224 | + | } | |
| 225 | + | if err := c.publish(ctx); err != nil { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | case ev, ok := <-c.ch.Config: | |
| 229 | + | if !ok { | |
| 230 | + | c.ch.Config = nil | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | if err := c.apply(ev.Config); err != nil { | |
| 234 | + | select { | |
| 235 | + | case c.ch.ConfigErrors <- err: | |
| 236 | + | default: | |
| 237 | + | } | |
| 238 | + | continue | |
| 239 | + | } | |
| 240 | + | if err := c.publish(ctx); err != nil { | |
| 241 | + | return nil | |
| 242 | + | } | |
| 243 | + | case ev, ok := <-c.ch.Input: | |
| 244 | + | if !ok { | |
| 245 | + | c.ch.Input = nil | |
| 246 | + | continue | |
| 247 | + | } | |
| 248 | + | key, ok := ev.(ports.KeyEvent) | |
| 249 | + | if !ok { | |
| 250 | + | continue | |
| 251 | + | } | |
| 252 | + | name := keyName(key.Keysym) | |
| 253 | + | if !key.Pressed { | |
| 254 | + | consumed := c.pressed[name] | |
| 255 | + | delete(c.pressed, name) | |
| 256 | + | if consumed { | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | if key.Pressed { | |
| 261 | + | if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok { | |
| 262 | + | c.pressed[name] = true | |
| 263 | + | effect := c.ws.Apply(action) | |
| 264 | + | if effect.Quit { | |
| 265 | + | return ErrQuit | |
| 266 | + | } | |
| 267 | + | if effect.Spawn { | |
| 268 | + | select { | |
| 269 | + | case <-ctx.Done(): | |
| 270 | + | return nil | |
| 271 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}: | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | if effect.Close != 0 { | |
| 275 | + | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | |
| 276 | + | return nil | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | if err := c.publish(ctx); err != nil { | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | continue | |
| 283 | + | } | |
| 284 | + | c.pressed[name] = false | |
| 285 | + | } | |
| 286 | + | if id, ok := c.ws.Focused(); ok { | |
| 287 | + | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |
| 288 | + | return nil | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| @@ -0,0 +1,293 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | "strings" | |||
| 9 | + | "unicode" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | var ErrQuit = errors.New("quit requested") | |||
| 13 | + | ||||
| 14 | + | // Channels connects the owner to adapters. scenes must have capacity 1: core is | |||
| 15 | + | // its only sender and may drain a stale scene; consumers only receive. | |||
| 16 | + | type Channels struct { | |||
| 17 | + | Client <-chan ports.ClientEvent | |||
| 18 | + | Input <-chan ports.InputEvent | |||
| 19 | + | Output <-chan ports.OutputEvent | |||
| 20 | + | Config <-chan ports.ConfigChanged | |||
| 21 | + | Commands chan<- ports.ClientCommand | |||
| 22 | + | Spawn chan<- ports.SpawnRequest | |||
| 23 | + | Scenes chan ports.Scene | |||
| 24 | + | ConfigErrors chan<- error | |||
| 25 | + | } | |||
| 26 | + | type binding struct { | |||
| 27 | + | mods ports.Mods | |||
| 28 | + | key string | |||
| 29 | + | } | |||
| 30 | + | type Core struct { | |||
| 31 | + | ch Channels | |||
| 32 | + | cfg ports.Config | |||
| 33 | + | ws Workspace | |||
| 34 | + | binds map[binding]Action | |||
| 35 | + | pressed map[string]bool | |||
| 36 | + | sent map[WindowID]ports.ConfigureWindow | |||
| 37 | + | focus WindowID | |||
| 38 | + | seq uint64 | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func keyName(s string) string { | |||
| 42 | + | if len(s) == 1 && unicode.IsLetter(rune(s[0])) { | |||
| 43 | + | return strings.ToLower(s) | |||
| 44 | + | } | |||
| 45 | + | return s | |||
| 46 | + | } | |||
| 47 | + | func parseBind(s, cmd string) (binding, error) { | |||
| 48 | + | parts := strings.Split(s, "+") | |||
| 49 | + | b := binding{key: keyName(parts[len(parts)-1])} | |||
| 50 | + | if b.key == "" { | |||
| 51 | + | return b, fmt.Errorf("invalid bind %q", s) | |||
| 52 | + | } | |||
| 53 | + | for _, p := range parts[:len(parts)-1] { | |||
| 54 | + | var m ports.Mods | |||
| 55 | + | switch p { | |||
| 56 | + | case "Cmd": | |||
| 57 | + | switch cmd { | |||
| 58 | + | case "super": | |||
| 59 | + | m = ports.ModSuper | |||
| 60 | + | case "alt": | |||
| 61 | + | m = ports.ModAlt | |||
| 62 | + | case "ctrl": | |||
| 63 | + | m = ports.ModCtrl | |||
| 64 | + | } | |||
| 65 | + | case "Shift": | |||
| 66 | + | m = ports.ModShift | |||
| 67 | + | case "Ctrl": | |||
| 68 | + | m = ports.ModCtrl | |||
| 69 | + | case "Alt": | |||
| 70 | + | m = ports.ModAlt | |||
| 71 | + | case "Super": | |||
| 72 | + | m = ports.ModSuper | |||
| 73 | + | } | |||
| 74 | + | if m == 0 || b.mods&m != 0 { | |||
| 75 | + | return b, fmt.Errorf("invalid bind %q", s) | |||
| 76 | + | } | |||
| 77 | + | b.mods |= m | |||
| 78 | + | } | |||
| 79 | + | return b, nil | |||
| 80 | + | } | |||
| 81 | + | func (c *Core) apply(cfg ports.Config) error { | |||
| 82 | + | width, err := ParseWidth(cfg.Layout.DefaultColumnWidth) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | presets := make([]Width, 0, len(cfg.Layout.Presets)) | |||
| 87 | + | for _, s := range cfg.Layout.Presets { | |||
| 88 | + | v, e := ParseWidth(s) | |||
| 89 | + | if e != nil { | |||
| 90 | + | return e | |||
| 91 | + | } | |||
| 92 | + | presets = append(presets, v) | |||
| 93 | + | } | |||
| 94 | + | if cfg.Layout.Gaps < 0 { | |||
| 95 | + | return fmt.Errorf("invalid gaps") | |||
| 96 | + | } | |||
| 97 | + | if cfg.Keyboard.CmdKey != "super" && cfg.Keyboard.CmdKey != "alt" && cfg.Keyboard.CmdKey != "ctrl" { | |||
| 98 | + | return fmt.Errorf("invalid cmd key") | |||
| 99 | + | } | |||
| 100 | + | binds := map[binding]Action{} | |||
| 101 | + | for combo, a := range cfg.Binds { | |||
| 102 | + | b, e := parseBind(combo, cfg.Keyboard.CmdKey) | |||
| 103 | + | if e != nil { | |||
| 104 | + | return e | |||
| 105 | + | } | |||
| 106 | + | if _, ok := binds[b]; ok { | |||
| 107 | + | return fmt.Errorf("duplicate bind %q", combo) | |||
| 108 | + | } | |||
| 109 | + | switch Action(a) { | |||
| 110 | + | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit: | |||
| 111 | + | default: | |||
| 112 | + | return fmt.Errorf("invalid action %q", a) | |||
| 113 | + | } | |||
| 114 | + | binds[b] = Action(a) | |||
| 115 | + | } | |||
| 116 | + | c.cfg = cfg | |||
| 117 | + | c.binds = binds | |||
| 118 | + | c.ws.SetDefaultWidth(width) | |||
| 119 | + | c.ws.SetPresets(presets) | |||
| 120 | + | c.ws.SetGaps(cfg.Layout.Gaps) | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 124 | + | if cap(ch.Scenes) != 1 { | |||
| 125 | + | return nil, fmt.Errorf("scenes must have capacity 1") | |||
| 126 | + | } | |||
| 127 | + | c := &Core{ch: ch, pressed: map[string]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |||
| 128 | + | if err := c.apply(cfg); err != nil { | |||
| 129 | + | return nil, err | |||
| 130 | + | } | |||
| 131 | + | return c, nil | |||
| 132 | + | } | |||
| 133 | + | func (c *Core) command(ctx context.Context, v ports.ClientCommand) error { | |||
| 134 | + | select { | |||
| 135 | + | case <-ctx.Done(): | |||
| 136 | + | return ctx.Err() | |||
| 137 | + | case c.ch.Commands <- v: | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | func (c *Core) publish(ctx context.Context) error { | |||
| 142 | + | c.seq++ | |||
| 143 | + | scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output.W, OutputHeight: c.ws.Output.H, Background: c.cfg.Background.Color, Windows: make([]ports.SceneWindow, 0)} | |||
| 144 | + | alive := map[WindowID]bool{} | |||
| 145 | + | focus, _ := c.ws.Focused() | |||
| 146 | + | for _, p := range c.ws.Layout() { | |||
| 147 | + | alive[p.ID] = true | |||
| 148 | + | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden}) | |||
| 149 | + | if p.Hidden { | |||
| 150 | + | if old, ok := c.sent[p.ID]; ok && old.Activated { | |||
| 151 | + | old.Activated = false | |||
| 152 | + | if err := c.command(ctx, old); err != nil { | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | c.sent[p.ID] = old | |||
| 156 | + | } | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | v := ports.ConfigureWindow{ID: p.ID, Width: p.Rect.W, Height: p.Rect.H, Fullscreen: p.Fullscreen, Activated: p.Focused} | |||
| 160 | + | if old, ok := c.sent[p.ID]; !ok || old != v { | |||
| 161 | + | if err := c.command(ctx, v); err != nil { | |||
| 162 | + | return err | |||
| 163 | + | } | |||
| 164 | + | c.sent[p.ID] = v | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | for id := range c.sent { | |||
| 168 | + | if !alive[id] { | |||
| 169 | + | delete(c.sent, id) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | if focus != c.focus { | |||
| 173 | + | if err := c.command(ctx, ports.FocusWindow{ID: focus}); err != nil { | |||
| 174 | + | return err | |||
| 175 | + | } | |||
| 176 | + | c.focus = focus | |||
| 177 | + | } | |||
| 178 | + | // Only the owner sends and drains; a renderer only receives. | |||
| 179 | + | select { | |||
| 180 | + | case c.ch.Scenes <- scene: | |||
| 181 | + | default: | |||
| 182 | + | select { | |||
| 183 | + | case <-c.ch.Scenes: | |||
| 184 | + | default: | |||
| 185 | + | } | |||
| 186 | + | select { | |||
| 187 | + | case c.ch.Scenes <- scene: | |||
| 188 | + | default: | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | func (c *Core) Run(ctx context.Context) error { | |||
| 194 | + | for { | |||
| 195 | + | select { | |||
| 196 | + | case <-ctx.Done(): | |||
| 197 | + | return nil | |||
| 198 | + | case ev, ok := <-c.ch.Client: | |||
| 199 | + | if !ok { | |||
| 200 | + | c.ch.Client = nil | |||
| 201 | + | continue | |||
| 202 | + | } | |||
| 203 | + | switch v := ev.(type) { | |||
| 204 | + | case ports.WindowMapped: | |||
| 205 | + | c.ws.AddWindow(v.ID) | |||
| 206 | + | case ports.WindowUnmapped: | |||
| 207 | + | c.ws.RemoveWindow(v.ID) | |||
| 208 | + | case ports.WindowFullscreenRequest: | |||
| 209 | + | c.ws.SetFullscreen(v.ID, v.Fullscreen) | |||
| 210 | + | } | |||
| 211 | + | if err := c.publish(ctx); err != nil { | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | case ev, ok := <-c.ch.Output: | |||
| 215 | + | if !ok { | |||
| 216 | + | c.ch.Output = nil | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | switch v := ev.(type) { | |||
| 220 | + | case ports.OutputMode: | |||
| 221 | + | c.ws.SetOutput(v.Width, v.Height) | |||
| 222 | + | case ports.OutputUsable: | |||
| 223 | + | c.ws.SetUsable(v.Rect) | |||
| 224 | + | } | |||
| 225 | + | if err := c.publish(ctx); err != nil { | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | case ev, ok := <-c.ch.Config: | |||
| 229 | + | if !ok { | |||
| 230 | + | c.ch.Config = nil | |||
| 231 | + | continue | |||
| 232 | + | } | |||
| 233 | + | if err := c.apply(ev.Config); err != nil { | |||
| 234 | + | select { | |||
| 235 | + | case c.ch.ConfigErrors <- err: | |||
| 236 | + | default: | |||
| 237 | + | } | |||
| 238 | + | continue | |||
| 239 | + | } | |||
| 240 | + | if err := c.publish(ctx); err != nil { | |||
| 241 | + | return nil | |||
| 242 | + | } | |||
| 243 | + | case ev, ok := <-c.ch.Input: | |||
| 244 | + | if !ok { | |||
| 245 | + | c.ch.Input = nil | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 248 | + | key, ok := ev.(ports.KeyEvent) | |||
| 249 | + | if !ok { | |||
| 250 | + | continue | |||
| 251 | + | } | |||
| 252 | + | name := keyName(key.Keysym) | |||
| 253 | + | if !key.Pressed { | |||
| 254 | + | consumed := c.pressed[name] | |||
| 255 | + | delete(c.pressed, name) | |||
| 256 | + | if consumed { | |||
| 257 | + | continue | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | if key.Pressed { | |||
| 261 | + | if action, ok := c.binds[binding{key: name, mods: key.Mods}]; ok { | |||
| 262 | + | c.pressed[name] = true | |||
| 263 | + | effect := c.ws.Apply(action) | |||
| 264 | + | if effect.Quit { | |||
| 265 | + | return ErrQuit | |||
| 266 | + | } | |||
| 267 | + | if effect.Spawn { | |||
| 268 | + | select { | |||
| 269 | + | case <-ctx.Done(): | |||
| 270 | + | return nil | |||
| 271 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: append([]string(nil), c.cfg.Terminal.Command...)}: | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | if effect.Close != 0 { | |||
| 275 | + | if err := c.command(ctx, ports.CloseWindow{ID: effect.Close}); err != nil { | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | if err := c.publish(ctx); err != nil { | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | continue | |||
| 283 | + | } | |||
| 284 | + | c.pressed[name] = false | |||
| 285 | + | } | |||
| 286 | + | if id, ok := c.ws.Focused(); ok { | |||
| 287 | + | if err := c.command(ctx, ports.ForwardKey{ID: id, Key: key}); err != nil { | |||
| 288 | + | return nil | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | } | |||
A internal/core/core_test.go +194 -0
| @@ -0,0 +1,194 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 7 | + | "github.com/bnema/nefertty/internal/core" | |
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func receive[T any](t *testing.T, ch <-chan T) T { | |
| 14 | + | t.Helper() | |
| 15 | + | select { | |
| 16 | + | case v := <-ch: | |
| 17 | + | return v | |
| 18 | + | case <-time.After(time.Second): | |
| 19 | + | t.Fatal("timed out") | |
| 20 | + | var zero T | |
| 21 | + | return zero | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | func TestOwner(t *testing.T) { | |
| 25 | + | cfg := config.Defaults() | |
| 26 | + | cfg.Keyboard.CmdKey = "alt" | |
| 27 | + | client := make(chan ports.ClientEvent, 128) | |
| 28 | + | input := make(chan ports.InputEvent, 32) | |
| 29 | + | output := make(chan ports.OutputEvent, 8) | |
| 30 | + | reload := make(chan ports.ConfigChanged, 8) | |
| 31 | + | commands := make(chan ports.ClientCommand, 256) | |
| 32 | + | spawn := make(chan ports.SpawnRequest, 8) | |
| 33 | + | scenes := make(chan ports.Scene, 1) | |
| 34 | + | errs := make(chan error, 8) | |
| 35 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}) | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 40 | + | defer cancel() | |
| 41 | + | done := make(chan error, 1) | |
| 42 | + | go func() { done <- c.Run(ctx) }() | |
| 43 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 44 | + | s := receive(t, scenes) | |
| 45 | + | if s.OutputWidth != 100 { | |
| 46 | + | t.Fatal(s) | |
| 47 | + | } | |
| 48 | + | client <- ports.WindowMapped{ID: 1} | |
| 49 | + | s = receive(t, scenes) | |
| 50 | + | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 38 { | |
| 51 | + | t.Fatal(s) | |
| 52 | + | } | |
| 53 | + | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 38, Height: 64, Activated: true}) { | |
| 54 | + | t.Fatal(v) | |
| 55 | + | } | |
| 56 | + | receive(t, commands) | |
| 57 | + | client <- ports.WindowMapped{ID: 2} | |
| 58 | + | s = receive(t, scenes) | |
| 59 | + | if len(s.Windows) != 2 { | |
| 60 | + | t.Fatal(s) | |
| 61 | + | } | |
| 62 | + | // Commands for focus changes and second mapping. | |
| 63 | + | for len(commands) > 0 { | |
| 64 | + | <-commands | |
| 65 | + | } | |
| 66 | + | client <- ports.WindowMapped{ID: 2} | |
| 67 | + | receive(t, scenes) | |
| 68 | + | if len(commands) != 0 { | |
| 69 | + | t.Fatal("duplicate configure") | |
| 70 | + | } | |
| 71 | + | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true} | |
| 72 | + | receive(t, scenes) | |
| 73 | + | if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" { | |
| 74 | + | t.Fatal(v) | |
| 75 | + | } | |
| 76 | + | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt} | |
| 77 | + | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |
| 78 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 79 | + | t.Fatal(v) | |
| 80 | + | } | |
| 81 | + | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | |
| 82 | + | receive(t, scenes) | |
| 83 | + | if v := receive(t, commands); v != (ports.CloseWindow{ID: 2}) { | |
| 84 | + | t.Fatal(v) | |
| 85 | + | } | |
| 86 | + | client <- ports.WindowUnmapped{ID: 2} | |
| 87 | + | receive(t, scenes) | |
| 88 | + | for len(commands) > 0 { | |
| 89 | + | <-commands | |
| 90 | + | } | |
| 91 | + | cfg.Layout.Gaps = 4 | |
| 92 | + | reload <- ports.ConfigChanged{Config: cfg} | |
| 93 | + | s = receive(t, scenes) | |
| 94 | + | if s.Windows[0].Rect.W != 44 { | |
| 95 | + | t.Fatal(s) | |
| 96 | + | } | |
| 97 | + | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 44, Height: 72, Activated: true}) { | |
| 98 | + | t.Fatal(v) | |
| 99 | + | } | |
| 100 | + | cfg.Layout.DefaultColumnWidth = "bad" | |
| 101 | + | reload <- ports.ConfigChanged{Config: cfg} | |
| 102 | + | receive(t, errs) | |
| 103 | + | for i := 0; i < 100; i++ { | |
| 104 | + | client <- ports.WindowMapped{ID: ports.WindowID(i + 3)} | |
| 105 | + | } | |
| 106 | + | input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true} | |
| 107 | + | if err := receive(t, done); !errors.Is(err, core.ErrQuit) { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | func TestCancelAndSceneCapacity(t *testing.T) { | |
| 112 | + | cfg := config.Defaults() | |
| 113 | + | if _, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 2)}); err == nil { | |
| 114 | + | t.Fatal("capacity") | |
| 115 | + | } | |
| 116 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 117 | + | c, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 1)}) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | done := make(chan error, 1) | |
| 122 | + | go func() { done <- c.Run(ctx) }() | |
| 123 | + | cancel() | |
| 124 | + | if err := receive(t, done); err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestKeyPressState(t *testing.T) { | |
| 130 | + | cfg := config.Defaults() | |
| 131 | + | client := make(chan ports.ClientEvent, 8) | |
| 132 | + | input := make(chan ports.InputEvent, 8) | |
| 133 | + | commands := make(chan ports.ClientCommand, 16) | |
| 134 | + | scenes := make(chan ports.Scene, 1) | |
| 135 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}) | |
| 136 | + | if err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 140 | + | done := make(chan error, 1) | |
| 141 | + | go func() { done <- c.Run(ctx) }() | |
| 142 | + | defer func() { cancel(); receive(t, done) }() | |
| 143 | + | client <- ports.WindowMapped{ID: 1} | |
| 144 | + | receive(t, scenes) | |
| 145 | + | receive(t, commands) | |
| 146 | + | receive(t, commands) | |
| 147 | + | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 148 | + | input <- press | |
| 149 | + | receive(t, scenes) | |
| 150 | + | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | |
| 151 | + | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 152 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { | |
| 153 | + | t.Fatal(v) | |
| 154 | + | } | |
| 155 | + | input <- ports.KeyEvent{Keysym: "Left"} | |
| 156 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 157 | + | t.Fatal(v) | |
| 158 | + | } | |
| 159 | + | input <- press | |
| 160 | + | receive(t, scenes) | |
| 161 | + | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 162 | + | receive(t, commands) | |
| 163 | + | input <- ports.KeyEvent{Keysym: "Left"} | |
| 164 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |
| 165 | + | t.Fatal(v) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestBoundReleaseSwallowed(t *testing.T) { | |
| 170 | + | cfg := config.Defaults() | |
| 171 | + | input := make(chan ports.InputEvent, 4) | |
| 172 | + | client := make(chan ports.ClientEvent, 2) | |
| 173 | + | commands := make(chan ports.ClientCommand, 8) | |
| 174 | + | scenes := make(chan ports.Scene, 1) | |
| 175 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 180 | + | done := make(chan error, 1) | |
| 181 | + | go func() { done <- c.Run(ctx) }() | |
| 182 | + | defer func() { cancel(); receive(t, done) }() | |
| 183 | + | client <- ports.WindowMapped{ID: 1} | |
| 184 | + | receive(t, scenes) | |
| 185 | + | receive(t, commands) | |
| 186 | + | receive(t, commands) | |
| 187 | + | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 188 | + | receive(t, scenes) | |
| 189 | + | input <- ports.KeyEvent{Keysym: "Left"} | |
| 190 | + | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |
| 191 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |
| 192 | + | t.Fatal(v) | |
| 193 | + | } | |
| 194 | + | } | |
| @@ -0,0 +1,194 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "github.com/bnema/nefertty/internal/adapters/config" | |||
| 7 | + | "github.com/bnema/nefertty/internal/core" | |||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func receive[T any](t *testing.T, ch <-chan T) T { | |||
| 14 | + | t.Helper() | |||
| 15 | + | select { | |||
| 16 | + | case v := <-ch: | |||
| 17 | + | return v | |||
| 18 | + | case <-time.After(time.Second): | |||
| 19 | + | t.Fatal("timed out") | |||
| 20 | + | var zero T | |||
| 21 | + | return zero | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | func TestOwner(t *testing.T) { | |||
| 25 | + | cfg := config.Defaults() | |||
| 26 | + | cfg.Keyboard.CmdKey = "alt" | |||
| 27 | + | client := make(chan ports.ClientEvent, 128) | |||
| 28 | + | input := make(chan ports.InputEvent, 32) | |||
| 29 | + | output := make(chan ports.OutputEvent, 8) | |||
| 30 | + | reload := make(chan ports.ConfigChanged, 8) | |||
| 31 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 32 | + | spawn := make(chan ports.SpawnRequest, 8) | |||
| 33 | + | scenes := make(chan ports.Scene, 1) | |||
| 34 | + | errs := make(chan error, 8) | |||
| 35 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs}) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 40 | + | defer cancel() | |||
| 41 | + | done := make(chan error, 1) | |||
| 42 | + | go func() { done <- c.Run(ctx) }() | |||
| 43 | + | output <- ports.OutputMode{Width: 100, Height: 80} | |||
| 44 | + | s := receive(t, scenes) | |||
| 45 | + | if s.OutputWidth != 100 { | |||
| 46 | + | t.Fatal(s) | |||
| 47 | + | } | |||
| 48 | + | client <- ports.WindowMapped{ID: 1} | |||
| 49 | + | s = receive(t, scenes) | |||
| 50 | + | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 38 { | |||
| 51 | + | t.Fatal(s) | |||
| 52 | + | } | |||
| 53 | + | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 38, Height: 64, Activated: true}) { | |||
| 54 | + | t.Fatal(v) | |||
| 55 | + | } | |||
| 56 | + | receive(t, commands) | |||
| 57 | + | client <- ports.WindowMapped{ID: 2} | |||
| 58 | + | s = receive(t, scenes) | |||
| 59 | + | if len(s.Windows) != 2 { | |||
| 60 | + | t.Fatal(s) | |||
| 61 | + | } | |||
| 62 | + | // Commands for focus changes and second mapping. | |||
| 63 | + | for len(commands) > 0 { | |||
| 64 | + | <-commands | |||
| 65 | + | } | |||
| 66 | + | client <- ports.WindowMapped{ID: 2} | |||
| 67 | + | receive(t, scenes) | |||
| 68 | + | if len(commands) != 0 { | |||
| 69 | + | t.Fatal("duplicate configure") | |||
| 70 | + | } | |||
| 71 | + | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true} | |||
| 72 | + | receive(t, scenes) | |||
| 73 | + | if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" { | |||
| 74 | + | t.Fatal(v) | |||
| 75 | + | } | |||
| 76 | + | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt} | |||
| 77 | + | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |||
| 78 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 2, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |||
| 79 | + | t.Fatal(v) | |||
| 80 | + | } | |||
| 81 | + | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | |||
| 82 | + | receive(t, scenes) | |||
| 83 | + | if v := receive(t, commands); v != (ports.CloseWindow{ID: 2}) { | |||
| 84 | + | t.Fatal(v) | |||
| 85 | + | } | |||
| 86 | + | client <- ports.WindowUnmapped{ID: 2} | |||
| 87 | + | receive(t, scenes) | |||
| 88 | + | for len(commands) > 0 { | |||
| 89 | + | <-commands | |||
| 90 | + | } | |||
| 91 | + | cfg.Layout.Gaps = 4 | |||
| 92 | + | reload <- ports.ConfigChanged{Config: cfg} | |||
| 93 | + | s = receive(t, scenes) | |||
| 94 | + | if s.Windows[0].Rect.W != 44 { | |||
| 95 | + | t.Fatal(s) | |||
| 96 | + | } | |||
| 97 | + | if v := receive(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 44, Height: 72, Activated: true}) { | |||
| 98 | + | t.Fatal(v) | |||
| 99 | + | } | |||
| 100 | + | cfg.Layout.DefaultColumnWidth = "bad" | |||
| 101 | + | reload <- ports.ConfigChanged{Config: cfg} | |||
| 102 | + | receive(t, errs) | |||
| 103 | + | for i := 0; i < 100; i++ { | |||
| 104 | + | client <- ports.WindowMapped{ID: ports.WindowID(i + 3)} | |||
| 105 | + | } | |||
| 106 | + | input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true} | |||
| 107 | + | if err := receive(t, done); !errors.Is(err, core.ErrQuit) { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | func TestCancelAndSceneCapacity(t *testing.T) { | |||
| 112 | + | cfg := config.Defaults() | |||
| 113 | + | if _, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 2)}); err == nil { | |||
| 114 | + | t.Fatal("capacity") | |||
| 115 | + | } | |||
| 116 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 117 | + | c, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 1)}) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | done := make(chan error, 1) | |||
| 122 | + | go func() { done <- c.Run(ctx) }() | |||
| 123 | + | cancel() | |||
| 124 | + | if err := receive(t, done); err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestKeyPressState(t *testing.T) { | |||
| 130 | + | cfg := config.Defaults() | |||
| 131 | + | client := make(chan ports.ClientEvent, 8) | |||
| 132 | + | input := make(chan ports.InputEvent, 8) | |||
| 133 | + | commands := make(chan ports.ClientCommand, 16) | |||
| 134 | + | scenes := make(chan ports.Scene, 1) | |||
| 135 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}) | |||
| 136 | + | if err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 140 | + | done := make(chan error, 1) | |||
| 141 | + | go func() { done <- c.Run(ctx) }() | |||
| 142 | + | defer func() { cancel(); receive(t, done) }() | |||
| 143 | + | client <- ports.WindowMapped{ID: 1} | |||
| 144 | + | receive(t, scenes) | |||
| 145 | + | receive(t, commands) | |||
| 146 | + | receive(t, commands) | |||
| 147 | + | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |||
| 148 | + | input <- press | |||
| 149 | + | receive(t, scenes) | |||
| 150 | + | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | |||
| 151 | + | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |||
| 152 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) { | |||
| 153 | + | t.Fatal(v) | |||
| 154 | + | } | |||
| 155 | + | input <- ports.KeyEvent{Keysym: "Left"} | |||
| 156 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |||
| 157 | + | t.Fatal(v) | |||
| 158 | + | } | |||
| 159 | + | input <- press | |||
| 160 | + | receive(t, scenes) | |||
| 161 | + | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |||
| 162 | + | receive(t, commands) | |||
| 163 | + | input <- ports.KeyEvent{Keysym: "Left"} | |||
| 164 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left"}}) { | |||
| 165 | + | t.Fatal(v) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 170 | + | cfg := config.Defaults() | |||
| 171 | + | input := make(chan ports.InputEvent, 4) | |||
| 172 | + | client := make(chan ports.ClientEvent, 2) | |||
| 173 | + | commands := make(chan ports.ClientCommand, 8) | |||
| 174 | + | scenes := make(chan ports.Scene, 1) | |||
| 175 | + | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Fatal(err) | |||
| 178 | + | } | |||
| 179 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 180 | + | done := make(chan error, 1) | |||
| 181 | + | go func() { done <- c.Run(ctx) }() | |||
| 182 | + | defer func() { cancel(); receive(t, done) }() | |||
| 183 | + | client <- ports.WindowMapped{ID: 1} | |||
| 184 | + | receive(t, scenes) | |||
| 185 | + | receive(t, commands) | |||
| 186 | + | receive(t, commands) | |||
| 187 | + | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |||
| 188 | + | receive(t, scenes) | |||
| 189 | + | input <- ports.KeyEvent{Keysym: "Left"} | |||
| 190 | + | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |||
| 191 | + | if v := receive(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) { | |||
| 192 | + | t.Fatal(v) | |||
| 193 | + | } | |||
| 194 | + | } | |||
M internal/core/layout.go +22 -2
| @@ -2,14 +2,15 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "github.com/bnema/nefertty/internal/ports" | |
| 5 | 6 | "math/bits" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | - | type WindowID uint64 | |
| 11 | + | type WindowID = ports.WindowID | |
| 11 | 12 | ||
| 12 | - | type Rect struct{ X, Y, W, H int } | |
| 13 | + | type Rect = ports.Rect | |
| 13 | 14 | ||
| 14 | 15 | type Width struct{ Num, Den, Pixels int } | |
| 15 | 16 | ||
| @@ -206,6 +207,25 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 206 | 207 | } | |
| 207 | 208 | w.scroll() | |
| 208 | 209 | } | |
| 210 | + | ||
| 211 | + | // SetFullscreen focuses an existing window and applies the requested state. | |
| 212 | + | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 213 | + | for i := range w.Columns { | |
| 214 | + | for j, v := range w.Columns[i].Windows { | |
| 215 | + | if v == id { | |
| 216 | + | w.Focus = i | |
| 217 | + | w.Columns[i].Focus = j | |
| 218 | + | if on { | |
| 219 | + | w.fullscreen = id | |
| 220 | + | } else if w.fullscreen == id { | |
| 221 | + | w.fullscreen = 0 | |
| 222 | + | } | |
| 223 | + | w.scroll() | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | } | |
| 209 | 229 | func (w *Workspace) SetOutput(width, height int) { | |
| 210 | 230 | if width < 0 { | |
| 211 | 231 | width = 0 | |
| @@ -2,14 +2,15 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 5 | "math/bits" | 6 | "math/bits" | ||
| 6 | "strconv" | 7 | "strconv" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | - | type WindowID uint64 | 11 | + | type WindowID = ports.WindowID |
| 11 | 12 | ||||
| 12 | - | type Rect struct{ X, Y, W, H int } | 13 | + | type Rect = ports.Rect |
| 13 | 14 | ||||
| 14 | type Width struct{ Num, Den, Pixels int } | 15 | type Width struct{ Num, Den, Pixels int } | ||
| 15 | 16 | ||||
| @@ -206,6 +207,25 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 206 | } | 207 | } | ||
| 207 | w.scroll() | 208 | w.scroll() | ||
| 208 | } | 209 | } | ||
| 210 | + | ||||
| 211 | + | // SetFullscreen focuses an existing window and applies the requested state. | |||
| 212 | + | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||
| 213 | + | for i := range w.Columns { | |||
| 214 | + | for j, v := range w.Columns[i].Windows { | |||
| 215 | + | if v == id { | |||
| 216 | + | w.Focus = i | |||
| 217 | + | w.Columns[i].Focus = j | |||
| 218 | + | if on { | |||
| 219 | + | w.fullscreen = id | |||
| 220 | + | } else if w.fullscreen == id { | |||
| 221 | + | w.fullscreen = 0 | |||
| 222 | + | } | |||
| 223 | + | w.scroll() | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 209 | func (w *Workspace) SetOutput(width, height int) { | 229 | func (w *Workspace) SetOutput(width, height int) { | ||
| 210 | if width < 0 { | 230 | if width < 0 { | ||
| 211 | width = 0 | 231 | width = 0 | ||
M internal/core/layout_test.go +16 -2
| @@ -82,7 +82,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 82 | 82 | } | |
| 83 | 83 | w.FocusWindow(-1) | |
| 84 | 84 | p := w.Layout() | |
| 85 | - | if p[0].Rect != (Rect{5, 5, 43, 33}) || p[1].Rect != (Rect{5, 43, 43, 33}) { | |
| 85 | + | if p[0].Rect != (Rect{X: 5, Y: 5, W: 43, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 43, H: 33}) { | |
| 86 | 86 | t.Fatal(p) | |
| 87 | 87 | } | |
| 88 | 88 | w.ToggleFullscreen() | |
| @@ -93,7 +93,7 @@ func TestStackAndLayout(t *testing.T) { | |||
| 93 | 93 | if w.Layout()[0].Fullscreen { | |
| 94 | 94 | t.Fatal("toggle") | |
| 95 | 95 | } | |
| 96 | - | w.SetUsable(Rect{10, 10, 60, 50}) | |
| 96 | + | w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50}) | |
| 97 | 97 | w.SetGaps(2) | |
| 98 | 98 | if w.Layout()[0].Rect.X != 12 { | |
| 99 | 99 | t.Fatal(w.Layout()) | |
| @@ -331,3 +331,17 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||
| 331 | 331 | t.Fatal(w.Layout()) | |
| 332 | 332 | } | |
| 333 | 333 | } | |
| 334 | + | ||
| 335 | + | func TestSetFullscreenStackDeactivation(t *testing.T) { | |
| 336 | + | w := workspace() | |
| 337 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, Focus: 0}} | |
| 338 | + | w.SetFullscreen(2, true) | |
| 339 | + | p := w.Layout() | |
| 340 | + | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | |
| 341 | + | t.Fatal(p) | |
| 342 | + | } | |
| 343 | + | w.SetFullscreen(2, false) | |
| 344 | + | if w.Layout()[0].Hidden { | |
| 345 | + | t.Fatal(w.Layout()) | |
| 346 | + | } | |
| 347 | + | } | |
| @@ -82,7 +82,7 @@ func TestStackAndLayout(t *testing.T) { | |||||
| 82 | } | 82 | } | ||
| 83 | w.FocusWindow(-1) | 83 | w.FocusWindow(-1) | ||
| 84 | p := w.Layout() | 84 | p := w.Layout() | ||
| 85 | - | if p[0].Rect != (Rect{5, 5, 43, 33}) || p[1].Rect != (Rect{5, 43, 43, 33}) { | 85 | + | if p[0].Rect != (Rect{X: 5, Y: 5, W: 43, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 43, H: 33}) { |
| 86 | t.Fatal(p) | 86 | t.Fatal(p) | ||
| 87 | } | 87 | } | ||
| 88 | w.ToggleFullscreen() | 88 | w.ToggleFullscreen() | ||
| @@ -93,7 +93,7 @@ func TestStackAndLayout(t *testing.T) { | |||||
| 93 | if w.Layout()[0].Fullscreen { | 93 | if w.Layout()[0].Fullscreen { | ||
| 94 | t.Fatal("toggle") | 94 | t.Fatal("toggle") | ||
| 95 | } | 95 | } | ||
| 96 | - | w.SetUsable(Rect{10, 10, 60, 50}) | 96 | + | w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50}) |
| 97 | w.SetGaps(2) | 97 | w.SetGaps(2) | ||
| 98 | if w.Layout()[0].Rect.X != 12 { | 98 | if w.Layout()[0].Rect.X != 12 { | ||
| 99 | t.Fatal(w.Layout()) | 99 | t.Fatal(w.Layout()) | ||
| @@ -331,3 +331,17 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) { | |||||
| 331 | t.Fatal(w.Layout()) | 331 | t.Fatal(w.Layout()) | ||
| 332 | } | 332 | } | ||
| 333 | } | 333 | } | ||
| 334 | + | ||||
| 335 | + | func TestSetFullscreenStackDeactivation(t *testing.T) { | |||
| 336 | + | w := workspace() | |||
| 337 | + | w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: w.DefaultWidth, Focus: 0}} | |||
| 338 | + | w.SetFullscreen(2, true) | |||
| 339 | + | p := w.Layout() | |||
| 340 | + | if !p[0].Hidden || !p[1].Fullscreen || !p[1].Focused { | |||
| 341 | + | t.Fatal(p) | |||
| 342 | + | } | |||
| 343 | + | w.SetFullscreen(2, false) | |||
| 344 | + | if w.Layout()[0].Hidden { | |||
| 345 | + | t.Fatal(w.Layout()) | |||
| 346 | + | } | |||
| 347 | + | } | |||
A internal/ports/messages.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | // ClientEvent carries wayland → core notifications. | |
| 4 | + | type ClientEvent interface{ clientEvent() } | |
| 5 | + | ||
| 6 | + | // WindowMapped carries wayland → core mapping. | |
| 7 | + | type WindowMapped struct { | |
| 8 | + | ID WindowID | |
| 9 | + | AppID string | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func (WindowMapped) clientEvent() {} | |
| 13 | + | ||
| 14 | + | // WindowUnmapped carries wayland → core removal. | |
| 15 | + | type WindowUnmapped struct{ ID WindowID } | |
| 16 | + | ||
| 17 | + | func (WindowUnmapped) clientEvent() {} | |
| 18 | + | ||
| 19 | + | // WindowFullscreenRequest carries wayland → core fullscreen requests. | |
| 20 | + | type WindowFullscreenRequest struct { | |
| 21 | + | ID WindowID | |
| 22 | + | Fullscreen bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (WindowFullscreenRequest) clientEvent() {} | |
| 26 | + | ||
| 27 | + | // Mods carries input → core modifier flags. | |
| 28 | + | type Mods uint8 | |
| 29 | + | ||
| 30 | + | const ( | |
| 31 | + | ModShift Mods = 1 << iota | |
| 32 | + | ModCtrl | |
| 33 | + | ModAlt | |
| 34 | + | ModSuper | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // InputEvent carries input → core notifications. | |
| 38 | + | type InputEvent interface{ inputEvent() } | |
| 39 | + | ||
| 40 | + | // KeyEvent carries input → core key transitions; Keysym is an xkb keysym name. | |
| 41 | + | type KeyEvent struct { | |
| 42 | + | Keysym string | |
| 43 | + | Mods Mods | |
| 44 | + | Pressed bool | |
| 45 | + | TimeMsec uint32 | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (KeyEvent) inputEvent() {} | |
| 49 | + | ||
| 50 | + | // OutputEvent carries output → core notifications. | |
| 51 | + | type OutputEvent interface{ outputEvent() } | |
| 52 | + | ||
| 53 | + | // OutputMode carries output → core resolution changes. | |
| 54 | + | type OutputMode struct{ Width, Height int } | |
| 55 | + | ||
| 56 | + | func (OutputMode) outputEvent() {} | |
| 57 | + | ||
| 58 | + | // OutputUsable carries output → core usable area after layer-shell exclusive zones. | |
| 59 | + | type OutputUsable struct{ Rect Rect } | |
| 60 | + | ||
| 61 | + | func (OutputUsable) outputEvent() {} | |
| 62 | + | ||
| 63 | + | // ConfigChanged carries config → core reloads. | |
| 64 | + | type ConfigChanged struct{ Config Config } | |
| 65 | + | ||
| 66 | + | // ClientCommand carries core → wayland commands. | |
| 67 | + | type ClientCommand interface{ clientCommand() } | |
| 68 | + | ||
| 69 | + | // ConfigureWindow carries core → wayland geometry and state. | |
| 70 | + | type ConfigureWindow struct { | |
| 71 | + | ID WindowID | |
| 72 | + | Width, Height int | |
| 73 | + | Fullscreen, Activated bool | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (ConfigureWindow) clientCommand() {} | |
| 77 | + | ||
| 78 | + | // CloseWindow carries core → wayland close requests. | |
| 79 | + | type CloseWindow struct{ ID WindowID } | |
| 80 | + | ||
| 81 | + | func (CloseWindow) clientCommand() {} | |
| 82 | + | ||
| 83 | + | // FocusWindow carries core → wayland keyboard focus (0 means none). | |
| 84 | + | type FocusWindow struct{ ID WindowID } | |
| 85 | + | ||
| 86 | + | func (FocusWindow) clientCommand() {} | |
| 87 | + | ||
| 88 | + | // ForwardKey carries core → wayland unbound keys. | |
| 89 | + | type ForwardKey struct { | |
| 90 | + | ID WindowID | |
| 91 | + | Key KeyEvent | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (ForwardKey) clientCommand() {} | |
| 95 | + | ||
| 96 | + | // SpawnRequest carries core → launcher process arguments. | |
| 97 | + | type SpawnRequest struct{ Argv []string } | |
| 98 | + | ||
| 99 | + | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | |
| 100 | + | type Scene struct { | |
| 101 | + | Seq uint64 | |
| 102 | + | OutputWidth, OutputHeight int | |
| 103 | + | Background string | |
| 104 | + | Windows []SceneWindow | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // SceneWindow carries core → renderer window placement. | |
| 108 | + | type SceneWindow struct { | |
| 109 | + | ID WindowID | |
| 110 | + | Rect Rect | |
| 111 | + | Focused, Fullscreen, Hidden bool | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // ClientEvent carries wayland → core notifications. | |||
| 4 | + | type ClientEvent interface{ clientEvent() } | |||
| 5 | + | ||||
| 6 | + | // WindowMapped carries wayland → core mapping. | |||
| 7 | + | type WindowMapped struct { | |||
| 8 | + | ID WindowID | |||
| 9 | + | AppID string | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func (WindowMapped) clientEvent() {} | |||
| 13 | + | ||||
| 14 | + | // WindowUnmapped carries wayland → core removal. | |||
| 15 | + | type WindowUnmapped struct{ ID WindowID } | |||
| 16 | + | ||||
| 17 | + | func (WindowUnmapped) clientEvent() {} | |||
| 18 | + | ||||
| 19 | + | // WindowFullscreenRequest carries wayland → core fullscreen requests. | |||
| 20 | + | type WindowFullscreenRequest struct { | |||
| 21 | + | ID WindowID | |||
| 22 | + | Fullscreen bool | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (WindowFullscreenRequest) clientEvent() {} | |||
| 26 | + | ||||
| 27 | + | // Mods carries input → core modifier flags. | |||
| 28 | + | type Mods uint8 | |||
| 29 | + | ||||
| 30 | + | const ( | |||
| 31 | + | ModShift Mods = 1 << iota | |||
| 32 | + | ModCtrl | |||
| 33 | + | ModAlt | |||
| 34 | + | ModSuper | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | // InputEvent carries input → core notifications. | |||
| 38 | + | type InputEvent interface{ inputEvent() } | |||
| 39 | + | ||||
| 40 | + | // KeyEvent carries input → core key transitions; Keysym is an xkb keysym name. | |||
| 41 | + | type KeyEvent struct { | |||
| 42 | + | Keysym string | |||
| 43 | + | Mods Mods | |||
| 44 | + | Pressed bool | |||
| 45 | + | TimeMsec uint32 | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (KeyEvent) inputEvent() {} | |||
| 49 | + | ||||
| 50 | + | // OutputEvent carries output → core notifications. | |||
| 51 | + | type OutputEvent interface{ outputEvent() } | |||
| 52 | + | ||||
| 53 | + | // OutputMode carries output → core resolution changes. | |||
| 54 | + | type OutputMode struct{ Width, Height int } | |||
| 55 | + | ||||
| 56 | + | func (OutputMode) outputEvent() {} | |||
| 57 | + | ||||
| 58 | + | // OutputUsable carries output → core usable area after layer-shell exclusive zones. | |||
| 59 | + | type OutputUsable struct{ Rect Rect } | |||
| 60 | + | ||||
| 61 | + | func (OutputUsable) outputEvent() {} | |||
| 62 | + | ||||
| 63 | + | // ConfigChanged carries config → core reloads. | |||
| 64 | + | type ConfigChanged struct{ Config Config } | |||
| 65 | + | ||||
| 66 | + | // ClientCommand carries core → wayland commands. | |||
| 67 | + | type ClientCommand interface{ clientCommand() } | |||
| 68 | + | ||||
| 69 | + | // ConfigureWindow carries core → wayland geometry and state. | |||
| 70 | + | type ConfigureWindow struct { | |||
| 71 | + | ID WindowID | |||
| 72 | + | Width, Height int | |||
| 73 | + | Fullscreen, Activated bool | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (ConfigureWindow) clientCommand() {} | |||
| 77 | + | ||||
| 78 | + | // CloseWindow carries core → wayland close requests. | |||
| 79 | + | type CloseWindow struct{ ID WindowID } | |||
| 80 | + | ||||
| 81 | + | func (CloseWindow) clientCommand() {} | |||
| 82 | + | ||||
| 83 | + | // FocusWindow carries core → wayland keyboard focus (0 means none). | |||
| 84 | + | type FocusWindow struct{ ID WindowID } | |||
| 85 | + | ||||
| 86 | + | func (FocusWindow) clientCommand() {} | |||
| 87 | + | ||||
| 88 | + | // ForwardKey carries core → wayland unbound keys. | |||
| 89 | + | type ForwardKey struct { | |||
| 90 | + | ID WindowID | |||
| 91 | + | Key KeyEvent | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (ForwardKey) clientCommand() {} | |||
| 95 | + | ||||
| 96 | + | // SpawnRequest carries core → launcher process arguments. | |||
| 97 | + | type SpawnRequest struct{ Argv []string } | |||
| 98 | + | ||||
| 99 | + | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | |||
| 100 | + | type Scene struct { | |||
| 101 | + | Seq uint64 | |||
| 102 | + | OutputWidth, OutputHeight int | |||
| 103 | + | Background string | |||
| 104 | + | Windows []SceneWindow | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // SceneWindow carries core → renderer window placement. | |||
| 108 | + | type SceneWindow struct { | |||
| 109 | + | ID WindowID | |||
| 110 | + | Rect Rect | |||
| 111 | + | Focused, Fullscreen, Hidden bool | |||
| 112 | + | } | |||
A internal/ports/types.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | // WindowID identifies a window. | |
| 4 | + | type WindowID uint64 | |
| 5 | + | ||
| 6 | + | // Rect is a pixel rectangle. | |
| 7 | + | type Rect struct{ X, Y, W, H int } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | // WindowID identifies a window. | |||
| 4 | + | type WindowID uint64 | |||
| 5 | + | ||||
| 6 | + | // Rect is a pixel rectangle. | |||
| 7 | + | type Rect struct{ X, Y, W, H int } | |||