detached cd738d1

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +783 -16
M .hexcheck.yaml +8 -0
@@ -11,3 +11,11 @@ rules:
1111 no-infra-imports-in-usecase: error
1212 no-framework-types-in-core: error
1313 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: error11 no-infra-imports-in-usecase: error
12 no-framework-types-in-core: error12 no-framework-types-in-core: error
13 no-infra-types-in-ports: error13 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.
22 all: false
33 packages:
44 github.com/bnema/nefertty/internal/ports:
55 config:
6- all: true
6+ all: false
77 dir: internal/mocks/{{.SrcPackageName}}
88 pkgname: '{{.SrcPackageName}}'
99 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: false2 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: true6+ 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) {
134134 sort.Strings(mods)
135135 return strings.Join(append(mods, p[len(p)-1]), "+"), nil
136136 }
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+}
137167 func Validate(c ports.Config) error {
138168 var errs []error
139169 add := func(path, msg string) { errs = append(errs, fmt.Errorf("%s: %s", path, msg)) }
@@ -189,13 +219,17 @@ func Validate(c ports.Config) error {
189219 combos := map[string]string{}
190220 for _, k := range keys {
191221 v := c.Binds[k]
192- n, e := normalize(k)
193- if e != nil {
222+ if _, e := normalize(k); e != nil {
194223 add("binds."+k, e.Error())
195- } else if old, ok := combos[n]; ok {
196- add("binds."+k, "duplicates "+old)
197224 } 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+ }
199233 }
200234 if !actions[v] {
201235 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]), "+"), nil135 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 []error168 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] = k225+ 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) {
4949 {"debug", "[log]\ndebug=['nope']", "log.debug[0]"},
5050 {"combo", "[binds]\n'Cmd+'='quit'", "binds.Cmd+"},
5151 {"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"},
5254 {"duplicate", "[binds]\n'Cmd+Shift+X'='quit'\n'Shift+Cmd+X'='quit'", "duplicates"},
5355 {"unknown", "[layout]\nnope=1", "layout.nope"},
5456 }
@@ -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
33
44 import (
55 "context"
6+ "errors"
7+ "sync"
68 "time"
79
10+ "github.com/bnema/nefertty/internal/core"
811 "github.com/bnema/nefertty/internal/logging"
912 "github.com/bnema/nefertty/internal/ports"
1013 )
@@ -15,14 +18,68 @@ type Options struct {
1518 Timeout time.Duration
1619 }
1720
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 {
1924 log := logging.For(ctx, "app")
2025 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
26+ var cancel context.CancelFunc
2127 if opts.Timeout > 0 {
22- var cancel context.CancelFunc
2328 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
2583 }
26- <-ctx.Done()
27- return nil
84+ return result
2885 }
@@ -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.Duration18 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
22
33 import (
44 "fmt"
5+ "github.com/bnema/nefertty/internal/ports"
56 "math/bits"
67 "strconv"
78 "strings"
89 )
910
10-type WindowID uint64
11+type WindowID = ports.WindowID
1112
12-type Rect struct{ X, Y, W, H int }
13+type Rect = ports.Rect
1314
1415 type Width struct{ Num, Den, Pixels int }
1516
@@ -206,6 +207,25 @@ func (w *Workspace) ToggleFullscreen() {
206207 }
207208 w.scroll()
208209 }
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+}
209229 func (w *Workspace) SetOutput(width, height int) {
210230 if width < 0 {
211231 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 uint6411+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 = 0231 width = 0
M internal/core/layout_test.go +16 -2
@@ -82,7 +82,7 @@ func TestStackAndLayout(t *testing.T) {
8282 }
8383 w.FocusWindow(-1)
8484 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}) {
8686 t.Fatal(p)
8787 }
8888 w.ToggleFullscreen()
@@ -93,7 +93,7 @@ func TestStackAndLayout(t *testing.T) {
9393 if w.Layout()[0].Fullscreen {
9494 t.Fatal("toggle")
9595 }
96- w.SetUsable(Rect{10, 10, 60, 50})
96+ w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50})
9797 w.SetGaps(2)
9898 if w.Layout()[0].Rect.X != 12 {
9999 t.Fatal(w.Layout())
@@ -331,3 +331,17 @@ func TestFullscreenPersistsAndScrolls(t *testing.T) {
331331 t.Fatal(w.Layout())
332332 }
333333 }
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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close