detached 6b65e8e

merge: multi-output (multiple monitors, hotplug, per-output layers)

commit
6b65e8e60c6111b2ea4e79657032f7055cc543df
author
brice · · 2026-09-24 21:12 UTC
merge of
6b4711d + bc1ec78 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
46 files · +2666 -795
M cmd/nefertty/main.go +14 -7
@@ -86,7 +86,7 @@ func run() error {
86 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)86 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
87 backend := flags.String("backend", "drm", "drm or headless")87 backend := flags.String("backend", "drm", "drm or headless")
88 screenshot := flags.String("screenshot", "", "write PNG frames to directory")88 screenshot := flags.String("screenshot", "", "write PNG frames to directory")
89- size := flags.String("size", "1920x1080", "headless output size WxH")89+ size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
90 inputPath := flags.String("input", "", "headless input script path (- for stdin)")90 inputPath := flags.String("input", "", "headless input script path (- for stdin)")
91 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")91 noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
92 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")92 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
@@ -108,11 +108,18 @@ func run() error {
108 fmt.Fprintln(os.Stderr, err)108 fmt.Fprintln(os.Stderr, err)
109 return err109 return err
110 }110 }
111- outW, outH, _, sizeErr := config.ParseMode(*size)111+ var sizes [][2]int
112- if sizeErr != nil {112+ for _, s := range strings.Split(*size, ",") {
113- err := usageError{fmt.Errorf("--size: %w", sizeErr)}113+ w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))
114- fmt.Fprintln(os.Stderr, err)114+ if sizeErr != nil || w == 0 {
115- return err115+ err := usageError{fmt.Errorf("--size: %w", sizeErr)}
116+ if sizeErr == nil {
117+ err = usageError{fmt.Errorf("--size: need WxH, got %q", s)}
118+ }
119+ fmt.Fprintln(os.Stderr, err)
120+ return err
121+ }
122+ sizes = append(sizes, [2]int{w, h})
116 }123 }
117 if *inputPath != "" && *backend != "headless" {124 if *inputPath != "" && *backend != "headless" {
118 err := usageError{fmt.Errorf("--input requires --backend=headless")}125 err := usageError{fmt.Errorf("--input requires --backend=headless")}
@@ -190,7 +197,7 @@ func run() error {
190 for _, w := range warnings {197 for _, w := range warnings {
191 configLog.Warn().Int("line", w.Line).Msg(w.Msg)198 configLog.Warn().Int("line", w.Line).Msg(w.Msg)
192 }199 }
193- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Width: outW, Height: outH, Script: script})200+ err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot, Sizes: sizes, Script: script})
194 // SIGINT and SIGTERM cancel the context and are clean exits.201 // SIGINT and SIGTERM cancel the context and are clean exits.
195 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {202 if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
196 err = nil203 err = nil
M internal/adapters/config/config.go +9 -2
@@ -36,8 +36,12 @@ var defaultBinds = []struct{ combo, action string }{
36 {"cmd+pagedown", "focus-workspace-down"},36 {"cmd+pagedown", "focus-workspace-down"},
37 {"cmd+shift+pageup", "move-to-workspace-up"},37 {"cmd+shift+pageup", "move-to-workspace-up"},
38 {"cmd+shift+pagedown", "move-to-workspace-down"},38 {"cmd+shift+pagedown", "move-to-workspace-down"},
39+ {"cmd+ctrl+left", "focus-monitor-left"},
40+ {"cmd+ctrl+right", "focus-monitor-right"},
41+ {"cmd+ctrl+shift+left", "move-workspace-to-monitor-left"},
42+ {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
39 }43 }
40-var actions = map[string]bool{"scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-to-workspace-up": true, "move-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}44+var actions = map[string]bool{"focus-monitor-left": true, "focus-monitor-right": true, "move-workspace-to-monitor-left": true, "move-workspace-to-monitor-right": true, "scale-up": true, "scale-down": true, "focus-workspace-up": true, "focus-workspace-down": true, "move-to-workspace-up": true, "move-to-workspace-down": true, "none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
41 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}45 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
42 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)46 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
43 47
@@ -482,7 +486,10 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
482 w.Hidden = b486 w.Hidden = b
483 return err487 return err
484 case "monitor":488 case "monitor":
485- return fmt.Errorf("not supported yet: one monitor only")489+ if v == "" {
490+ return fmt.Errorf("needs a connector (DP-2) or a monitor key")
491+ }
492+ w.Monitor = v
486 case "max-columns":493 case "max-columns":
487 return positive(&w.MaxColumns, v, 16)494 return positive(&w.MaxColumns, v, 16)
488 case "overflow":495 case "overflow":
M internal/adapters/config/config_test.go +3 -3
@@ -21,7 +21,7 @@ func parseString(t *testing.T, s string) (ports.Config, []Warning) {
21 21
22 func TestDefaultsAndLoad(t *testing.T) {22 func TestDefaultsAndLoad(t *testing.T) {
23 d := Defaults()23 d := Defaults()
24- if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 36 || d.Layout.MaxColumns != 2 {24+ if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 40 || d.Layout.MaxColumns != 2 {
25 t.Fatalf("defaults: %+v", d)25 t.Fatalf("defaults: %+v", d)
26 }26 }
27 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {27 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -249,7 +249,7 @@ bind.cmd+x = workspace typo
249 workspace.lost.hidden = on249 workspace.lost.hidden = on
250 `)250 `)
251 want := []ports.WorkspaceConfig{251 want := []ports.WorkspaceConfig{
252- {Name: "dev", Hidden: true, MaxColumns: 3},252+ {Name: "dev", Monitor: "DP-2", Hidden: true, MaxColumns: 3},
253 {Name: "web", Overflow: "scroll"},253 {Name: "web", Overflow: "scroll"},
254 {Name: "lost", Hidden: true},254 {Name: "lost", Hidden: true},
255 }255 }
@@ -263,7 +263,7 @@ workspace.lost.hidden = on
263 for _, x := range w {263 for _, x := range w {
264 lines = append(lines, x.Line)264 lines = append(lines, x.Line)
265 }265 }
266- if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) {266+ if !reflect.DeepEqual(lines, []int{6, 7, 8, 10, 11, 12}) {
267 t.Fatal(w)267 t.Fatal(w)
268 }268 }
269 }269 }
A internal/adapters/drm/card.go +157 -0
@@ -0,0 +1,157 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "slices"
8+
9+ "github.com/bnema/zerowrap"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Card is one DRM device and the outputs it drives. Scan finds connected
14+// connectors; each gets its own Output (CRTC, buffers, cursor) run by one
15+// goroutine. One reader goroutine reads page-flip events for all of them
16+// and hands each to its output by CRTC.
17+type Card struct {
18+ fd int
19+ path string
20+ want Want
21+ log zerowrap.Logger
22+ crtcs []uint32
23+ // outputs by connector name; owned by the goroutine that calls Scan.
24+ outputs map[string]*Output
25+ flips map[uint32]chan int // by CRTC; set before the reader starts
26+}
27+
28+// OpenCard reads the card's CRTCs. fd stays owned by the caller.
29+func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error) {
30+ crtcs, _, err := resources(fd)
31+ if err != nil {
32+ return nil, fmt.Errorf("%s: %w", path, err)
33+ }
34+ flips := make(map[uint32]chan int, len(crtcs))
35+ for _, c := range crtcs {
36+ flips[c] = make(chan int, 4)
37+ }
38+ return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips}, nil
39+}
40+
41+// Path is the device path, e.g. /dev/dri/card1.
42+func (c *Card) Path() string { return c.path }
43+
44+// SetWant replaces the connector config; the next Scan applies it.
45+func (c *Card) SetWant(w Want) { c.want = w }
46+
47+// Scan compares connectors with the outputs driven now. It returns outputs to
48+// start (newly connected, or with a new mode after Release), names of
49+// outputs to stop because they are gone (disconnected or disabled), and
50+// names to stop because they need a new mode. A stopped output must be
51+// closed by its goroutine before its CRTC is reused: call Release.
52+func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
53+ _, ids, err := resources(c.fd)
54+ if err != nil {
55+ return nil, nil, nil, err
56+ }
57+ seen := map[string]bool{}
58+ for _, id := range ids {
59+ conn, err := readConnector(c.fd, id)
60+ if err != nil {
61+ c.log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
62+ continue
63+ }
64+ if !c.want.usable(conn) {
65+ continue
66+ }
67+ seen[conn.name] = true
68+ mode := c.want.pickMode(conn)
69+ if o := c.outputs[conn.name]; o != nil {
70+ if o.mode == mode {
71+ continue
72+ }
73+ replaced = append(replaced, conn.name)
74+ continue
75+ }
76+ o, err := c.open(conn, mode)
77+ if err != nil {
78+ c.log.Warn().Err(err).Str("connector", conn.name).Msg("output unusable")
79+ continue
80+ }
81+ c.outputs[conn.name] = o
82+ added = append(added, o)
83+ }
84+ for name := range c.outputs {
85+ if !seen[name] {
86+ removed = append(removed, name)
87+ }
88+ }
89+ slices.Sort(removed)
90+ slices.Sort(replaced)
91+ return added, removed, replaced, nil
92+}
93+
94+// Release forgets an output closed by its goroutine, freeing its CRTC for the
95+// next Scan.
96+func (c *Card) Release(name string) { delete(c.outputs, name) }
97+
98+// Outputs returns the driven outputs.
99+func (c *Card) Outputs() []*Output {
100+ out := make([]*Output, 0, len(c.outputs))
101+ for _, o := range c.outputs {
102+ out = append(out, o)
103+ }
104+ slices.SortFunc(out, func(a, b *Output) int {
105+ if a.conn.name < b.conn.name {
106+ return -1
107+ }
108+ return 1
109+ })
110+ return out
111+}
112+
113+func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
114+ used := map[uint32]bool{}
115+ for _, o := range c.outputs {
116+ used[o.crtc] = true
117+ }
118+ crtc, err := pickCrtc(c.fd, conn, c.crtcs, used)
119+ if err != nil {
120+ return nil, err
121+ }
122+ return newOutput(c, conn, mode, crtc)
123+}
124+
125+// ReadEvents reads page-flip events until ctx ends and routes them to the
126+// output of their CRTC. Run it once per card.
127+func (c *Card) ReadEvents(ctx context.Context) error {
128+ buf := make([]byte, 1024)
129+ for ctx.Err() == nil {
130+ fds := []unix.PollFd{{Fd: int32(c.fd), Events: unix.POLLIN}}
131+ n, err := unix.Poll(fds, 100)
132+ if err != nil && !errors.Is(err, unix.EINTR) {
133+ return fmt.Errorf("drm poll: %w", err)
134+ }
135+ if n <= 0 {
136+ continue
137+ }
138+ m, err := unix.Read(c.fd, buf)
139+ if err != nil {
140+ if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
141+ continue
142+ }
143+ return fmt.Errorf("drm read: %w", err)
144+ }
145+ for _, crtc := range flipCrtcs(buf[:m]) {
146+ ch := c.flips[crtc]
147+ if ch == nil {
148+ continue
149+ }
150+ select {
151+ case ch <- 1:
152+ default: // the output is busy; the flip is counted on its next event
153+ }
154+ }
155+ }
156+ return nil
157+}
M internal/adapters/drm/cursor.go +20 -2
@@ -74,6 +74,7 @@ type Cursor struct {
74 size int // buffer side74 size int // buffer side
75 hotX, hotY int75 hotX, hotY int
76 shown bool76 shown bool
77+ hidden bool // Hide was called; the next move shows it again
77 plane cursorPlane78 plane cursorPlane
78 79
79 mu sync.Mutex // guards x, y and stats; never held across an ioctl80 mu sync.Mutex // guards x, y and stats; never held across an ioctl
@@ -147,7 +148,11 @@ func (c *Cursor) start() {
147 // fails too and Reapply restores the cursor.148 // fails too and Reapply restores the cursor.
148 _ = c.plane.WaitVblank()149 _ = c.plane.WaitVblank()
149 c.io.Lock()150 c.io.Lock()
150- if c.shown && c.buf != nil {151+ if c.hidden {
152+ // A move shows it again, even before its first image.
153+ c.hidden = false
154+ _ = c.apply()
155+ } else if c.shown && c.buf != nil {
151 x, y := c.position()156 x, y := c.position()
152 v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}157 v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
153 // Errors while another session owns the card (VT switched158 // Errors while another session owns the card (VT switched
@@ -195,7 +200,7 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
195 200
196 // apply (re)attaches the buffer at the current position. Callers hold c.io.201 // apply (re)attaches the buffer at the current position. Callers hold c.io.
197 func (c *Cursor) apply() error {202 func (c *Cursor) apply() error {
198- if !c.shown || c.buf == nil {203+ if !c.shown || c.hidden || c.buf == nil {
199 return nil204 return nil
200 }205 }
201 x, y := c.position()206 x, y := c.position()
@@ -216,6 +221,19 @@ func (c *Cursor) Move(x, y float64) {
216 }221 }
217 }222 }
218 223
224+// Hide removes the cursor from the output until the next move (the pointer
225+// is on another output).
226+func (c *Cursor) Hide() {
227+ c.io.Lock()
228+ defer c.io.Unlock()
229+ if c.buf == nil || c.hidden {
230+ return
231+ }
232+ c.hidden = true
233+ v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
234+ _ = c.plane.Set(&v)
235+}
236+
219 // Reapply shows the cursor again after a modeset (VT switch back).237 // Reapply shows the cursor again after a modeset (VT switch back).
220 func (c *Cursor) Reapply() error {238 func (c *Cursor) Reapply() error {
221 c.io.Lock()239 c.io.Lock()
A internal/adapters/drm/hotplug.go +104 -0
@@ -0,0 +1,104 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/purego"
10+ "golang.org/x/sys/unix"
11+)
12+
13+// Hotplug reports connector changes through a udev netlink monitor on the
14+// drm subsystem (the kernel sends HOTPLUG=1 on the card). libudev is loaded
15+// with purego.
16+
17+var (
18+ udevOnce sync.Once
19+ udevErr error
20+
21+ udevNew func() uintptr
22+ udevUnref func(uintptr) uintptr
23+ monitorNew func(udev uintptr, name string) uintptr
24+ monitorUnref func(uintptr) uintptr
25+ monitorFilter func(mon uintptr, subsystem string, devtype uintptr) int32
26+ monitorEnable func(uintptr) int32
27+ monitorFD func(uintptr) int32
28+ monitorReceive func(uintptr) uintptr
29+ deviceUnref func(uintptr) uintptr
30+ devicePropertyVal func(dev uintptr, key string) string
31+)
32+
33+func loadUdev() error {
34+ udevOnce.Do(func() {
35+ lib, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
36+ if err != nil {
37+ udevErr = fmt.Errorf("open libudev.so.1: %w", err)
38+ return
39+ }
40+ defer func() {
41+ if r := recover(); r != nil {
42+ udevErr = fmt.Errorf("libudev: %v", r)
43+ }
44+ }()
45+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "udev_"+name) }
46+ reg(&udevNew, "new")
47+ reg(&udevUnref, "unref")
48+ reg(&monitorNew, "monitor_new_from_netlink")
49+ reg(&monitorUnref, "monitor_unref")
50+ reg(&monitorFilter, "monitor_filter_add_match_subsystem_devtype")
51+ reg(&monitorEnable, "monitor_enable_receiving")
52+ reg(&monitorFD, "monitor_get_fd")
53+ reg(&monitorReceive, "monitor_receive_device")
54+ reg(&deviceUnref, "device_unref")
55+ reg(&devicePropertyVal, "device_get_property_value")
56+ })
57+ return udevErr
58+}
59+
60+// WatchHotplug sends on changed after every DRM hotplug event until ctx
61+// ends. Events that arrive while a send is pending are merged.
62+func WatchHotplug(ctx context.Context, changed chan<- struct{}) error {
63+ if err := loadUdev(); err != nil {
64+ return err
65+ }
66+ udev := udevNew()
67+ if udev == 0 {
68+ return errors.New("udev_new failed")
69+ }
70+ defer udevUnref(udev)
71+ mon := monitorNew(udev, "udev")
72+ if mon == 0 {
73+ return errors.New("udev_monitor_new_from_netlink failed")
74+ }
75+ defer monitorUnref(mon)
76+ if monitorFilter(mon, "drm", 0) < 0 || monitorEnable(mon) < 0 {
77+ return errors.New("udev monitor setup failed")
78+ }
79+ fd := monitorFD(mon)
80+ for ctx.Err() == nil {
81+ fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
82+ n, err := unix.Poll(fds, 100)
83+ if err != nil && !errors.Is(err, unix.EINTR) {
84+ return fmt.Errorf("udev poll: %w", err)
85+ }
86+ if n <= 0 {
87+ continue
88+ }
89+ dev := monitorReceive(mon)
90+ if dev == 0 {
91+ continue
92+ }
93+ hot := devicePropertyVal(dev, "HOTPLUG") == "1"
94+ deviceUnref(dev)
95+ if !hot {
96+ continue
97+ }
98+ select {
99+ case changed <- struct{}{}:
100+ default:
101+ }
102+ }
103+ return nil
104+}
M internal/adapters/drm/kms.go +35 -47
@@ -1,8 +1,8 @@
1-// Package drm drives one KMS output with raw ioctls and dumb buffers.1+// Package drm drives KMS outputs with raw ioctls and dumb buffers.
2 package drm2 package drm
3 3
4 import (4 import (
5- "errors"5+ "encoding/binary"
6 "fmt"6 "fmt"
7 "math"7 "math"
8 "unsafe"8 "unsafe"
@@ -114,14 +114,16 @@ type connector struct {
114 114
115 var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}115 var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
116 116
117-// Want selects a connector and mode; see ports.OutputConfig.117+// Want configures connectors; see ports.OutputConfig.
118 type Want struct {118 type Want struct {
119- Name string
120- W, H int
121- Hz float64 // 0: highest refresh at W x H
122 Disabled map[string]bool119 Disabled map[string]bool
123- // Strict accepts only the connector named Name (used to search every card first).120+ // Modes maps a connector to W, H and Hz (0: highest refresh at W x H).
124- Strict bool121+ Modes map[string][3]float64
122+}
123+
124+// usable reports whether a connector should be driven.
125+func (w Want) usable(c connector) bool {
126+ return c.connected && len(c.modes) > 0 && !w.Disabled[c.name]
125 }127 }
126 128
127 // refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.129 // refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.
@@ -142,37 +144,20 @@ func (m modeInfo) refreshMilli() int {
142 return int(r)144 return int(r)
143 }145 }
144 146
145-// pickConnector applies want: the named connector if connected, else the first147+// pickMode returns the configured mode of the connector, else its preferred
146-// connected one that is not disabled. The mode is the requested one, else preferred.148+// one, else the first.
147-func pickConnector(conns []connector, want Want) (connector, modeInfo, error) {149+func (w Want) pickMode(c connector) modeInfo {
148- var chosen *connector150+ if m, ok := w.Modes[c.name]; ok {
149- for i := range conns {151+ if mode, ok := pickMode(c.modes, int(m[0]), int(m[1]), m[2]); ok {
150- c := &conns[i]152+ return mode
151- if !c.connected || len(c.modes) == 0 || want.Disabled[c.name] {
152- continue
153- }
154- if c.name == want.Name {
155- chosen = c
156- break
157- }
158- if chosen == nil && !want.Strict {
159- chosen = c
160- }
161- }
162- if chosen == nil {
163- return connector{}, modeInfo{}, errors.New("no connected display")
164- }
165- if chosen.name == want.Name && want.W > 0 {
166- if m, ok := pickMode(chosen.modes, want.W, want.H, want.Hz); ok {
167- return *chosen, m, nil
168 }153 }
169 }154 }
170- for _, m := range chosen.modes {155+ for _, m := range c.modes {
171 if m.Type&modeTypePrefered != 0 {156 if m.Type&modeTypePrefered != 0 {
172- return *chosen, m, nil157+ return m
173 }158 }
174 }159 }
175- return *chosen, chosen.modes[0], nil160+ return c.modes[0]
176 }161 }
177 162
178 // pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.163 // pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.
@@ -245,8 +230,9 @@ func readConnector(fd int, id uint32) (connector, error) {
245 }, nil230 }, nil
246 }231 }
247 232
248-// pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows.233+// pickCrtc prefers the connector's current encoder CRTC, else any free CRTC
249-func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {234+// an encoder allows. used holds CRTCs already driving other outputs.
235+func pickCrtc(fd int, c connector, crtcs []uint32, used map[uint32]bool) (uint32, error) {
250 encs := c.encoders236 encs := c.encoders
251 if c.encoderID != 0 {237 if c.encoderID != 0 {
252 encs = append([]uint32{c.encoderID}, encs...)238 encs = append([]uint32{c.encoderID}, encs...)
@@ -256,16 +242,16 @@ func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {
256 if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {242 if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {
257 continue243 continue
258 }244 }
259- if id == c.encoderID && e.crtcID != 0 {245+ if id == c.encoderID && e.crtcID != 0 && !used[e.crtcID] {
260 return e.crtcID, nil246 return e.crtcID, nil
261 }247 }
262 for i, crtc := range crtcs {248 for i, crtc := range crtcs {
263- if e.possibleCrtcs&(1<<i) != 0 {249+ if e.possibleCrtcs&(1<<i) != 0 && !used[crtc] {
264 return crtc, nil250 return crtc, nil
265 }251 }
266 }252 }
267 }253 }
268- return 0, fmt.Errorf("no CRTC for %s", c.name)254+ return 0, fmt.Errorf("no free CRTC for %s", c.name)
269 }255 }
270 256
271 type dumbBuffer struct {257 type dumbBuffer struct {
@@ -333,19 +319,21 @@ func flip(fd int, crtc, fb uint32) error {
333 return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))319 return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
334 }320 }
335 321
336-// countFlipEvents parses DRM events read from the card fd.322+// flipCrtcs parses DRM events read from the card fd and returns the CRTC of
337-func countFlipEvents(buf []byte) int {323+// each completed page flip.
338- n := 0324+func flipCrtcs(buf []byte) []uint32 {
325+ var out []uint32
339 for len(buf) >= 8 {326 for len(buf) >= 8 {
340- typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24327+ typ := binary.LittleEndian.Uint32(buf)
341- length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24)328+ length := int(binary.LittleEndian.Uint32(buf[4:]))
342 if length < 8 || length > len(buf) {329 if length < 8 || length > len(buf) {
343 break330 break
344 }331 }
345- if typ == eventFlipDone {332+ // struct drm_event_vblank: base(8) user_data(8) sec usec sequence crtc_id.
346- n++333+ if typ == eventFlipDone && length >= 32 {
334+ out = append(out, binary.LittleEndian.Uint32(buf[28:]))
347 }335 }
348 buf = buf[length:]336 buf = buf[length:]
349 }337 }
350- return n338+ return out
351 }339 }
M internal/adapters/drm/kms_test.go +23 -31
@@ -30,7 +30,7 @@ func TestStructSizesMatchIoctls(t *testing.T) {
30 }30 }
31 }31 }
32 32
33-func TestPickConnector(t *testing.T) {33+func TestWantPicksMode(t *testing.T) {
34 mode := func(w, h uint16, hz uint32, pref bool) modeInfo {34 mode := func(w, h uint16, hz uint32, pref bool) modeInfo {
35 // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).35 // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).
36 m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}36 m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}
@@ -40,33 +40,24 @@ func TestPickConnector(t *testing.T) {
40 return m40 return m
41 }41 }
42 lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}42 lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}
43- tv := connector{name: "HDMI-A-1", connected: true, modes: []modeInfo{mode(3840, 2160, 60, true), mode(3840, 2160, 120, false)}}
44 off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}43 off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}
45 for _, tc := range []struct {44 for _, tc := range []struct {
46- name string45+ name string
47- conns []connector46+ want Want
48- want Want47+ mode string
49- conn string
50- mode string
51- err bool
52 }{48 }{
53- {"none", nil, Want{}, "", "", true},49+ {"first preferred", Want{}, "5120x2160@165.000"},
54- {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false},50+ {"closest refresh", Want{Modes: map[string][3]float64{"DP-2": {5120, 2160, 99}}}, "5120x2160@100.000"},
55- {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false},51+ {"highest refresh", Want{Modes: map[string][3]float64{"DP-2": {5120, 2160, 0}}}, "5120x2160@165.000"},
56- {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false},52+ {"unknown mode falls back to preferred", Want{Modes: map[string][3]float64{"DP-2": {800, 600, 0}}}, "5120x2160@165.000"},
57- {"named mode highest refresh", []connector{lg}, Want{Name: "DP-2", W: 5120, H: 2160}, "DP-2", "5120x2160@165.000", false},
58- {"unknown mode falls back to preferred", []connector{lg}, Want{Name: "DP-2", W: 800, H: 600}, "DP-2", "5120x2160@165.000", false},
59- {"missing name falls back", []connector{tv}, Want{Name: "DP-2"}, "HDMI-A-1", "3840x2160@60.000", false},
60- {"disabled skipped", []connector{tv, lg}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "DP-2", "5120x2160@165.000", false},
61- {"strict finds named", []connector{tv, lg}, Want{Name: "DP-2", Strict: true}, "DP-2", "5120x2160@165.000", false},
62- {"strict without named", []connector{tv}, Want{Name: "DP-2", Strict: true}, "", "", true},
63- {"all disabled", []connector{tv}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "", "", true},
64 } {53 } {
65- c, m, err := pickConnector(tc.conns, tc.want)54+ if m := tc.want.pickMode(lg); m.String() != tc.mode {
66- if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) {55+ t.Errorf("%s: got %s", tc.name, m)
67- t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err)
68 }56 }
69 }57 }
58+ if (Want{}).usable(off) || !(Want{}).usable(lg) || (Want{Disabled: map[string]bool{"DP-2": true}}).usable(lg) {
59+ t.Fatal("usable")
60+ }
70 }61 }
71 62
72 func TestRefreshMilliLG(t *testing.T) {63 func TestRefreshMilliLG(t *testing.T) {
@@ -91,18 +82,19 @@ func TestParseEDID(t *testing.T) {
91 }82 }
92 }83 }
93 84
94-func TestCountFlipEvents(t *testing.T) {85+func TestFlipCrtcs(t *testing.T) {
95- ev := func(typ uint32, n int) []byte {86+ ev := func(typ, crtc uint32) []byte {
96- b := make([]byte, n)87+ b := make([]byte, 32)
97 binary.LittleEndian.PutUint32(b, typ)88 binary.LittleEndian.PutUint32(b, typ)
98- binary.LittleEndian.PutUint32(b[4:], uint32(n))89+ binary.LittleEndian.PutUint32(b[4:], 32)
90+ binary.LittleEndian.PutUint32(b[28:], crtc)
99 return b91 return b
100 }92 }
101- buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...)93+ buf := append(append(ev(eventFlipDone, 41), ev(1, 7)...), ev(eventFlipDone, 42)...)
102- if n := countFlipEvents(buf); n != 2 {94+ if got := flipCrtcs(buf); len(got) != 2 || got[0] != 41 || got[1] != 42 {
103- t.Fatalf("got %d flips", n)95+ t.Fatalf("got %v", got)
104 }96 }
105- if n := countFlipEvents(buf[:20]); n != 0 {97+ if got := flipCrtcs(buf[:20]); len(got) != 0 {
106- t.Fatalf("truncated: got %d", n)98+ t.Fatalf("truncated: got %v", got)
107 }99 }
108 }100 }
M internal/adapters/drm/output.go +37 -100
@@ -5,7 +5,6 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "slices"7 "slices"
8- "sync"
9 "time"8 "time"
10 9
11 "github.com/bnema/nefertty/internal/ports"10 "github.com/bnema/nefertty/internal/ports"
@@ -13,9 +12,11 @@ import (
13 "golang.org/x/sys/unix"12 "golang.org/x/sys/unix"
14 )13 )
15 14
16-// Output is one KMS connector driven with two dumb buffers.15+// Output is one KMS connector driven with two dumb buffers. One goroutine
16+// runs it (Run) and closes it.
17 type Output struct {17 type Output struct {
18 fd int18 fd int
19+ flipped <-chan int // page flips of this CRTC, from Card.ReadEvents
19 crtc uint3220 crtc uint32
20 conn connector21 conn connector
21 mode modeInfo22 mode modeInfo
@@ -34,74 +35,39 @@ type Output struct {
34 // pixels on a side.35 // pixels on a side.
35 type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)36 type CursorLoader func(scale float64, limit int) (ports.CursorImage, error)
36 37
37-// Info describes the chosen output for clients (wl_output, xdg-output).38+// newOutput allocates buffers and the cursor for a connector on crtc.
38-type Info struct {39+func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
39- Name string40+ log := card.log
40- Monitor Monitor41+ o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name)}
41- Width, Height int42+ var err error
42- RefreshMilli int43+ if o.saved, err = getCrtc(card.fd, crtc); err != nil {
43- PhysicalW, PhysicalH int // millimetres
44-}
45-
46-// Open picks a connector on the card fd according to want and allocates buffers.
47-func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) {
48- crtcs, ids, err := resources(fd)
49- if err != nil {
50- return nil, err
51- }
52- var conns []connector
53- for _, id := range ids {
54- c, err := readConnector(fd, id)
55- if err != nil {
56- log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
57- continue
58- }
59- ev := log.Info().Str("connector", c.name).Bool("connected", c.connected)
60- if c.connected {
61- mon := readMonitor(card, c.name)
62- modes := make([]string, 0, len(c.modes))
63- for _, m := range c.modes {
64- modes = append(modes, m.String())
65- }
66- ev = ev.Str("make", mon.Make).Str("model", mon.Model).Int("mm_w", c.mmW).Int("mm_h", c.mmH).Strs("modes", modes)
67- }
68- ev.Msg("connector")
69- conns = append(conns, c)
70- }
71- c, mode, err := pickConnector(conns, want)
72- if err != nil {
73- return nil, fmt.Errorf("%s: %w", card, err)
74- }
75- crtc, err := pickCrtc(fd, c, crtcs)
76- if err != nil {
77- return nil, err
78- }
79- o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card, c.name)}
80- if want.Name != "" && want.Name != c.name {
81- log.Warn().Str("wanted", want.Name).Str("using", c.name).Msg("configured output not connected")
82- }
83- if o.saved, err = getCrtc(fd, crtc); err != nil {
84 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")44 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
85 }45 }
86 for i := range o.bufs {46 for i := range o.bufs {
87- if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {47+ if o.bufs[i], err = newDumb(card.fd, o.Width(), o.Height()); err != nil {
88 o.Close()48 o.Close()
89 return nil, err49 return nil, err
90 }50 }
91 }51 }
92- if o.cursor, err = newCursor(fd, crtc, slices.Index(crtcs, crtc)); err != nil {52+ if o.cursor, err = newCursor(card.fd, crtc, slices.Index(card.crtcs, crtc)); err != nil {
93- log.Warn().Err(err).Msg("no hardware cursor")53+ log.Warn().Err(err).Str("connector", c.name).Msg("no hardware cursor")
94 }54 }
95- log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")55+ log.Info().Str("card", card.path).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
96 return o, nil56 return o, nil
97 }57 }
98 58
99 // Cursor is the hardware cursor, or nil when the driver has none.59 // Cursor is the hardware cursor, or nil when the driver has none.
100 func (o *Output) Cursor() *Cursor { return o.cursor }60 func (o *Output) Cursor() *Cursor { return o.cursor }
101 61
102-// Info returns the chosen connector, monitor and mode.62+// Info describes the output for core and clients. Unknown EDID fields are empty.
103-func (o *Output) Info() Info {63+func (o *Output) Info() ports.OutputInfo {
104- return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}64+ known := func(v string) string {
65+ if v == "Unknown" {
66+ return ""
67+ }
68+ return v
69+ }
70+ return ports.OutputInfo{Name: o.conn.name, Make: known(o.monitor.Make), Model: known(o.monitor.Model), Serial: o.monitor.Serial, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
105 }71 }
106 72
107 func (o *Output) Width() int { return int(o.mode.HDisplay) }73 func (o *Output) Width() int { return int(o.mode.HDisplay) }
@@ -130,7 +96,8 @@ func (o *Output) present(r ports.Renderer) error {
130 return nil96 return nil
131 }97 }
132 98
133-// Close restores the CRTC state found at startup and frees buffers.99+// Close restores the CRTC state found at startup, when it had a mode, and
100+// frees buffers.
134 func (o *Output) Close() {101 func (o *Output) Close() {
135 if o.cursor != nil {102 if o.cursor != nil {
136 o.cursor.close()103 o.cursor.close()
@@ -156,19 +123,21 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
156 return fmt.Errorf("create renderer: %w", err)123 return fmt.Errorf("create renderer: %w", err)
157 }124 }
158 defer r.Close()125 defer r.Close()
159- if err := o.modeset(); err != nil {126+ // The seat sends its state first; while switched away (e.g. a monitor
160- return err127+ // plugged in on another VT) the modeset waits for the enable.
128+ enabled := true
129+ select {
130+ case enabled = <-active:
131+ default:
132+ }
133+ if enabled {
134+ if err := o.modeset(); err != nil {
135+ return err
136+ }
161 }137 }
162- events := make(chan int, 4)
163- evErr := make(chan error, 1)
164- evCtx, stop := context.WithCancel(ctx)
165- var reader sync.WaitGroup
166- reader.Go(func() { o.readEvents(evCtx, events, evErr) })
167- // Join the reader before the card fd can be closed by the caller.
168- defer func() { stop(); reader.Wait() }()
169 surfaces := make(map[ports.WindowID]ports.SurfaceContent)138 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
170 var scene ports.Scene139 var scene ports.Scene
171- haveScene, dirty, enabled := false, false, true140+ haveScene, dirty := false, false
172 cursorScale := -1.0 // not loaded yet141 cursorScale := -1.0 // not loaded yet
173 frame := 0142 frame := 0
174 stats := time.NewTicker(10 * time.Second)143 stats := time.NewTicker(10 * time.Second)
@@ -177,8 +146,6 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
177 select {146 select {
178 case <-ctx.Done():147 case <-ctx.Done():
179 return nil148 return nil
180- case err := <-evErr:
181- return err
182 case on := <-active:149 case on := <-active:
183 // Modeset on every enable: a fast disable+enable can coalesce to one true.150 // Modeset on every enable: a fast disable+enable can coalesce to one true.
184 if on {151 if on {
@@ -195,7 +162,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
195 }162 }
196 enabled = on163 enabled = on
197 o.log.Info().Bool("enabled", on).Msg("output")164 o.log.Info().Bool("enabled", on).Msg("output")
198- case n := <-events:165+ case n := <-o.flipped:
199 if !o.pending {166 if !o.pending {
200 continue // stale event from before a modeset167 continue // stale event from before a modeset
201 }168 }
@@ -266,33 +233,3 @@ func (o *Output) setCursor(load CursorLoader, scale float64) {
266 }233 }
267 o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")234 o.log.Info().Float64("scale", scale).Int("w", img.W).Int("h", img.H).Msg("cursor image")
268 }235 }
269-
270-func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
271- buf := make([]byte, 1024)
272- for ctx.Err() == nil {
273- fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}}
274- n, err := unix.Poll(fds, 100)
275- if err != nil && !errors.Is(err, unix.EINTR) {
276- errs <- fmt.Errorf("drm poll: %w", err)
277- return
278- }
279- if n <= 0 {
280- continue
281- }
282- m, err := unix.Read(o.fd, buf)
283- if err != nil {
284- if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
285- continue
286- }
287- errs <- fmt.Errorf("drm read: %w", err)
288- return
289- }
290- if c := countFlipEvents(buf[:m]); c > 0 {
291- select {
292- case events <- c:
293- case <-ctx.Done():
294- return
295- }
296- }
297- }
298-}
M internal/adapters/headless/cursor.go +15 -4
@@ -10,14 +10,22 @@ import (
10 // Cursor is the software cursor drawn into screenshots; it plays the role of10 // Cursor is the software cursor drawn into screenshots; it plays the role of
11 // the DRM cursor plane. Move is called from the input goroutine.11 // the DRM cursor plane. Move is called from the input goroutine.
12 type Cursor struct {12 type Cursor struct {
13- mu sync.Mutex13+ mu sync.Mutex
14- x, y float6414+ x, y float64
15- img ports.CursorImage15+ img ports.CursorImage
16+ hidden bool
16 }17 }
17 18
18 func (c *Cursor) Move(x, y float64) {19 func (c *Cursor) Move(x, y float64) {
19 c.mu.Lock()20 c.mu.Lock()
20- c.x, c.y = x, y21+ c.x, c.y, c.hidden = x, y, false
22+ c.mu.Unlock()
23+}
24+
25+// Hide stops drawing the cursor until the next move.
26+func (c *Cursor) Hide() {
27+ c.mu.Lock()
28+ c.hidden = true
21 c.mu.Unlock()29 c.mu.Unlock()
22 }30 }
23 31
@@ -31,6 +39,9 @@ func (c *Cursor) set(img ports.CursorImage) {
31 func (c *Cursor) draw(dst *image.RGBA) {39 func (c *Cursor) draw(dst *image.RGBA) {
32 c.mu.Lock()40 c.mu.Lock()
33 defer c.mu.Unlock()41 defer c.mu.Unlock()
42+ if c.hidden {
43+ return
44+ }
34 img := c.img45 img := c.img
35 ox, oy := int(c.x)-img.HotX, int(c.y)-img.HotY46 ox, oy := int(c.x)-img.HotX, int(c.y)-img.HotY
36 b := dst.Bounds()47 b := dst.Bounds()
M internal/adapters/headlessinput/input.go +9 -5
@@ -15,9 +15,12 @@ import (
15 )15 )
16 16
17 // Run owns the keymap and converts script lines into input events. A keymap17 // Run owns the keymap and converts script lines into input events. A keymap
18-// received on keymaps replaces the current one, which is closed.18+// received on keymaps replaces the current one, which is closed. Scripted
19-// moveCursor, when set, places the software cursor on every scripted move.19+// moves are global logical coordinates; moveCursor, when set, places the
20-func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, moveCursor func(x, y float64), log zerowrap.Logger) error {20+// software cursor of the output under them (physical position on it), using
21+// the latest layout from layouts.
22+func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script <-chan string, input chan<- ports.InputEvent, layouts <-chan ports.Layout, moveCursor func(output string, x, y float64), log zerowrap.Logger) error {
23+ var layout ports.Layout
21 defer func() { km.Close() }()24 defer func() { km.Close() }()
22 emit := func(code uint32, down bool) error {25 emit := func(code uint32, down bool) error {
23 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))26 ev := km.Key(code, down, uint32(time.Now().UnixMilli()))
@@ -79,6 +82,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
79 km.Close()82 km.Close()
80 km = next83 km = next
81 log.Info().Msg("keymap replaced")84 log.Info().Msg("keymap replaced")
85+ case layout = <-layouts:
82 case line, ok := <-script:86 case line, ok := <-script:
83 if !ok {87 if !ok {
84 return nil88 return nil
@@ -109,8 +113,8 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
109 x, ex := strconv.ParseFloat(fields[1], 64)113 x, ex := strconv.ParseFloat(fields[1], 64)
110 y, ey := strconv.ParseFloat(fields[2], 64)114 y, ey := strconv.ParseFloat(fields[2], 64)
111 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {115 if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) {
112- if moveCursor != nil {116+ if o, ok := layout.At(x, y); ok && moveCursor != nil {
113- moveCursor(x, y)117+ moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale)
114 }118 }
115 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {119 if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil {
116 return err120 return err
M internal/adapters/headlessinput/input_test.go +2 -2
@@ -31,7 +31,7 @@ func TestScript(t *testing.T) {
31 script <- tt.line31 script <- tt.line
32 close(script)32 close(script)
33 input := make(chan ports.InputEvent, 32)33 input := make(chan ports.InputEvent, 32)
34- if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {34+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
35 t.Fatal(err)35 t.Fatal(err)
36 }36 }
37 if len(input) != len(tt.names) {37 if len(input) != len(tt.names) {
@@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) {
67 }67 }
68 close(script)68 close(script)
69 input := make(chan ports.InputEvent, 8)69 input := make(chan ports.InputEvent, 8)
70- if err := Run(context.Background(), km, nil, script, input, nil, logging.For(context.Background(), "input")); err != nil {70+ if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
71 t.Fatal(err)71 t.Fatal(err)
72 }72 }
73 if len(input) != 5 {73 if len(input) != 5 {
M internal/adapters/libinput/libinput.go +71 -23
@@ -132,12 +132,15 @@ type Options struct {
132 SeatName string132 SeatName string
133 Keymap *xkb.Keymap133 Keymap *xkb.Keymap
134 // Keymaps replaces Keymap live; Run takes ownership and closes the old one.134 // Keymaps replaces Keymap live; Run takes ownership and closes the old one.
135- Keymaps <-chan *xkb.Keymap135+ Keymaps <-chan *xkb.Keymap
136- Width, Height int136+ // Layout is the initial output layout; Layouts replaces it live.
137- Active <-chan bool137+ Layout ports.Layout
138- // MoveCursor, when set, places the hardware cursor at the new physical138+ Layouts <-chan ports.Layout
139- // position as soon as motion is read, before core sees the event.139+ Active <-chan bool
140- MoveCursor func(x, y float64)140+ // MoveCursor, when set, places the hardware cursor as soon as motion is
141+ // read, before core sees the event: the output under the pointer and
142+ // the physical position on it.
143+ MoveCursor func(output string, x, y float64)
141 Log zerowrap.Logger144 Log zerowrap.Logger
142 }145 }
143 146
@@ -168,11 +171,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
168 if assignSeat(li, name) != 0 {171 if assignSeat(li, name) != 0 {
169 return fmt.Errorf("libinput_udev_assign_seat %s failed", name)172 return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
170 }173 }
171- p := pointer{w: float64(opts.Width), h: float64(opts.Height)}174+ p := newPointer(opts.Layout)
172- p.x, p.y = p.w/2, p.h/2175+ p.moved(opts.MoveCursor)
173- if opts.MoveCursor != nil {
174- opts.MoveCursor(p.x, p.y)
175- }
176 fd := getFD(li)176 fd := getFD(li)
177 fwd := newForwarder()177 fwd := newForwarder()
178 fwdCtx, stopFwd := context.WithCancel(ctx)178 fwdCtx, stopFwd := context.WithCancel(ctx)
@@ -199,6 +199,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
199 opts.Keymap.Close()199 opts.Keymap.Close()
200 opts.Keymap = km200 opts.Keymap = km
201 opts.Log.Info().Msg("keymap replaced")201 opts.Log.Info().Msg("keymap replaced")
202+ case l := <-opts.Layouts:
203+ p.setLayout(l)
204+ p.moved(opts.MoveCursor)
202 default:205 default:
203 }206 }
204 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}207 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -213,7 +216,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
213 opts.Log.Warn().Msg("libinput_dispatch failed")216 opts.Log.Warn().Msg("libinput_dispatch failed")
214 }217 }
215 for ev := getEvent(li); ev != 0; ev = getEvent(li) {218 for ev := getEvent(li); ev != 0; ev = getEvent(li) {
216- out, err := translate(ev, opts, &p)219+ out, err := translate(ev, opts, p)
217 eventDestroy(ev)220 eventDestroy(ev)
218 if err != nil {221 if err != nil {
219 return err222 return err
@@ -251,17 +254,15 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
251 case evPointerMotion:254 case evPointerMotion:
252 pe := pointerEvent(ev)255 pe := pointerEvent(ev)
253 x, y := p.move(pointerDX(pe), pointerDY(pe))256 x, y := p.move(pointerDX(pe), pointerDY(pe))
254- if opts.MoveCursor != nil {257+ p.moved(opts.MoveCursor)
255- opts.MoveCursor(x, y)
256- }
257 log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")258 log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
258 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil259 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
259 case evPointerAbs:260 case evPointerAbs:
261+ // Absolute devices (tablets, VMs) map to the whole layout.
260 pe := pointerEvent(ev)262 pe := pointerEvent(ev)
261- x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))263+ b := p.bounds()
262- if opts.MoveCursor != nil {264+ x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H)))
263- opts.MoveCursor(x, y)265+ p.moved(opts.MoveCursor)
264- }
265 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil266 return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
266 case evPointerButton:267 case evPointerButton:
267 pe := pointerEvent(ev)268 pe := pointerEvent(ev)
@@ -305,12 +306,59 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
305 return hotkeyNone, 0306 return hotkeyNone, 0
306 }307 }
307 308
308-type pointer struct{ x, y, w, h float64 }309+// pointer tracks the cursor in global logical coordinates. Relative motion
310+// is divided by the scale of the output under the pointer, so it moves the
311+// same number of physical pixels on every output.
312+type pointer struct {
313+ x, y float64
314+ layout ports.Layout
315+}
316+
317+// newPointer starts at the centre of the first output.
318+func newPointer(l ports.Layout) *pointer {
319+ p := &pointer{layout: l}
320+ if len(l) > 0 {
321+ p.x, p.y = float64(l[0].X)+float64(l[0].Width)/2, float64(l[0].Y)+float64(l[0].Height)/2
322+ }
323+ return p
324+}
309 325
310-func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) }326+// setLayout keeps the pointer where it is, or clamps it onto an output.
327+func (p *pointer) setLayout(l ports.Layout) {
328+ p.layout = l
329+ p.x, p.y = l.Clamp(p.x, p.y, p.x, p.y)
330+}
331+
332+func (p *pointer) scale() float64 {
333+ if o, ok := p.layout.At(p.x, p.y); ok && o.Scale > 0 {
334+ return o.Scale
335+ }
336+ return 1
337+}
338+
339+func (p *pointer) move(dx, dy float64) (float64, float64) {
340+ s := p.scale()
341+ return p.set(p.x+dx/s, p.y+dy/s)
342+}
311 343
312 func (p *pointer) set(x, y float64) (float64, float64) {344 func (p *pointer) set(x, y float64) (float64, float64) {
313- p.x = min(max(x, 0), p.w-1)345+ p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)
314- p.y = min(max(y, 0), p.h-1)
315 return p.x, p.y346 return p.x, p.y
316 }347 }
348+
349+// bounds is the rectangle holding every output.
350+func (p *pointer) bounds() ports.Rect {
351+ var r ports.Rect
352+ for _, o := range p.layout {
353+ r.W = max(r.W, o.X+o.Width)
354+ r.H = max(r.H, o.Y+o.Height)
355+ }
356+ return r
357+}
358+
359+// moved reports the physical position on the output under the pointer.
360+func (p *pointer) moved(move func(output string, x, y float64)) {
361+ if o, ok := p.layout.At(p.x, p.y); ok && move != nil {
362+ move(o.Info.Name, (p.x-float64(o.X))*o.Scale, (p.y-float64(o.Y))*o.Scale)
363+ }
364+}
M internal/adapters/libinput/libinput_test.go +25 -4
@@ -29,12 +29,33 @@ func TestHotkey(t *testing.T) {
29 }29 }
30 }30 }
31 31
32-func TestPointerClamp(t *testing.T) {32+func TestPointerAcrossOutputs(t *testing.T) {
33- p := pointer{x: 10, y: 10, w: 100, h: 50}33+ layout := ports.Layout{
34- if x, y := p.move(-20, 100); x != 0 || y != 49 {34+ {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1},
35+ {Info: ports.OutputInfo{Name: "B"}, X: 100, Width: 200, Height: 100, Scale: 2},
36+ }
37+ p := newPointer(layout)
38+ if p.x != 50 || p.y != 25 {
39+ t.Fatal(p.x, p.y)
40+ }
41+ // Off the bottom of A: clamped to A.
42+ if x, y := p.move(-80, 100); x != 0 || y != 49 {
35 t.Fatalf("got %v %v", x, y)43 t.Fatalf("got %v %v", x, y)
36 }44 }
37- if x, y := p.move(30.5, -5); x != 30.5 || y != 44 {45+ // Into B, where deltas count in physical pixels (scale 2).
46+ p.set(150, 20)
47+ if x, y := p.move(20, 10); x != 160 || y != 25 {
38 t.Fatalf("got %v %v", x, y)48 t.Fatalf("got %v %v", x, y)
39 }49 }
50+ var out string
51+ var px, py float64
52+ p.moved(func(o string, x, y float64) { out, px, py = o, x, y })
53+ if out != "B" || px != 120 || py != 50 {
54+ t.Fatal(out, px, py)
55+ }
56+ // B unplugged: the pointer lands on A.
57+ p.setLayout(layout[:1])
58+ if p.x != 99 || p.y != 25 {
59+ t.Fatal(p.x, p.y)
60+ }
40 }61 }
M internal/adapters/seat/seat.go +8 -0
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "runtime"8 "runtime"
9+ "slices"
9 "sync"10 "sync"
10 "unsafe"11 "unsafe"
11 12
@@ -138,6 +139,13 @@ func (s *Seat) Subscribe() <-chan bool {
138 return c139 return c
139 }140 }
140 141
142+// Unsubscribe stops sending to a channel from Subscribe.
143+func (s *Seat) Unsubscribe(c <-chan bool) {
144+ s.mu.Lock()
145+ defer s.mu.Unlock()
146+ s.subs = slices.DeleteFunc(s.subs, func(x chan bool) bool { return x == c })
147+}
148+
141 func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {149 func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
142 if ctx.Err() != nil {150 if ctx.Err() != nil {
143 return ctx.Err()151 return ctx.Err()
M internal/adapters/wayland/commands_test.go +1 -1
@@ -41,7 +41,7 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) {
41 // must be absorbed without core blocking.41 // must be absorbed without core blocking.
42 func TestCommandThroughput(t *testing.T) {42 func TestCommandThroughput(t *testing.T) {
43 cmds := make(chan ports.ClientCommand, 32)43 cmds := make(chan ports.ClientCommand, 32)
44- s, err := New(Options{RuntimeDir: t.TempDir(), OutputWidth: 1920, OutputHeight: 1080}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))44+ s, err := New(Options{RuntimeDir: t.TempDir(), Outputs: testOutputs}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
45 if err != nil {45 if err != nil {
46 t.Fatal(err)46 t.Fatal(err)
47 }47 }
M internal/adapters/wayland/globals.go +3 -31
@@ -11,8 +11,8 @@ import (
11 func registerGlobals(d *server.Display, o Options, s *Server) error {11 func registerGlobals(d *server.Display, o Options, s *Server) error {
12 for _, register := range []func() error{12 for _, register := range []func() error{
13 func() error { return registerXDG(d, s) },13 func() error { return registerXDG(d, s) },
14- func() error { return registerLayer(d, o, s) },14+ func() error { return registerLayer(d, s) },
15- func() error { return registerXDGOutput(d, o, s) },15+ func() error { return registerXDGOutput(d, s) },
16 func() error { return registerDecoration(d, s) },16 func() error { return registerDecoration(d, s) },
17 func() error { return registerScale(d, s) },17 func() error { return registerScale(d, s) },
18 func() error {18 func() error {
@@ -32,30 +32,6 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
32 }32 }
33 })33 })
34 },34 },
35- func() error {
36- return wayland.NewOutputGlobal(d, 4, func(c server.Client, v, id uint32) {
37- r, e := wayland.NewOutput(c, int32(v), id, output{})
38- if e != nil {
39- return
40- }
41- s.scale.outputs = append(s.scale.outputs, r)
42- r.OnDestroy = func() { s.scale.outputs = removeItem(s.scale.outputs, r) }
43- info := o.output()
44- r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0)
45- // The mode stays physical; the scale tells clients how to divide it.
46- r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
47- if v >= 2 {
48- r.SendScale(s.scale.integerScale())
49- }
50- if v >= 4 {
51- r.SendName(info.Name)
52- r.SendDescription(info.Description)
53- }
54- if v >= 2 {
55- r.SendDone()
56- }
57- })
58- },
59 func() error {35 func() error {
60 return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {36 return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) {
61 r, e := wayland.NewSeat(c, int32(v), id, seat{s})37 r, e := wayland.NewSeat(c, int32(v), id, seat{s})
@@ -89,7 +65,7 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
89 if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil {65 if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil {
90 state.wl = w66 state.wl = w
91 c.server.surfaces[w.Resource] = state67 c.server.surfaces[w.Resource] = state
92- c.server.sendSurfaceScale(w)68+ state.sendScale()
93 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }69 w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
94 }70 }
95 }71 }
@@ -247,10 +223,6 @@ func (b *buffer) content(id ports.WindowID) (content ports.SurfaceContent, ok bo
247 return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Stride: b.stride, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), Pixels: pixels}, true223 return ports.SurfaceContent{ID: id, Width: b.width, Height: b.height, Stride: b.stride, Opaque: b.format == uint32(wayland.ShmFormatXrgb8888), Pixels: pixels}, true
248 }224 }
249 225
250-type output struct{}
251-
252-func (output) Release(*wayland.Output) {}
253-
254 type seat struct{ server *Server }226 type seat struct{ server *Server }
255 227
256 func (h seat) GetPointer(r *wayland.Seat, id uint32) {228 func (h seat) GetPointer(r *wayland.Seat, id uint32) {
M internal/adapters/wayland/keyboard_test.go +1 -1
@@ -32,7 +32,7 @@ func keyboardServer(t *testing.T, setup ...func(*Server)) (*Server, chan ports.C
32 dir := t.TempDir()32 dir := t.TempDir()
33 events := make(chan ports.ClientEvent, 16)33 events := make(chan ports.ClientEvent, 16)
34 commands := make(chan ports.ClientCommand, 32)34 commands := make(chan ports.ClientCommand, 32)
35- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymapText(t), RepeatRate: 25, RepeatDelay: 600}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))35+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Keymap: keymapText(t), RepeatRate: 25, RepeatDelay: 600}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
36 if err != nil {36 if err != nil {
37 t.Fatal(err)37 t.Fatal(err)
38 }38 }
M internal/adapters/wayland/layer.go +39 -5
@@ -26,15 +26,19 @@ type layerSurface struct {
26 pending, current layerState26 pending, current layerState
27 configured, acked, mapped bool27 configured, acked, mapped bool
28 serials []uint3228 serials []uint32
29+ // output is the output the client asked for, else the focused one at
30+ // creation. The surface is closed when it is unplugged.
31+ output *output
32+ closed bool // output gone: commits are ignored until destroy
29 }33 }
30 34
31-func registerLayer(d *server.Display, o Options, s *Server) error {35+func registerLayer(d *server.Display, s *Server) error {
32 return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {36 return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) {
33 _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s})37 _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s})
34 })38 })
35 }39 }
36 func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}40 func (h layerShell) Destroy(*wlrlayershell.ZwlrLayerShellV1) {}
37-func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, _ *wayland.Output, layer uint32, namespace string) {41+func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32, w *wayland.Surface, wl *wayland.Output, layer uint32, namespace string) {
38 if w == nil || h.server.surfaces[w.Resource] == nil {42 if w == nil || h.server.surfaces[w.Resource] == nil {
39 return43 return
40 }44 }
@@ -47,7 +51,12 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32
47 r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")51 r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer")
48 return52 return
49 }53 }
50- l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace}54+ out := h.server.outputOf(wl)
55+ gone := wl != nil && out == nil // its output was unplugged
56+ if wl == nil {
57+ out = h.server.outputByName("")
58+ }
59+ l := &layerSurface{shell: h, surface: state, id: h.server.nextWindow, namespace: namespace, output: out}
51 l.pending.layer = ports.Layer(layer)60 l.pending.layer = ports.Layer(layer)
52 resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)61 resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l)
53 if err != nil {62 if err != nil {
@@ -59,6 +68,9 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32
59 state.layer = l68 state.layer = l
60 state.role = l.commit69 state.role = l.commit
61 h.server.layers[l.id] = l70 h.server.layers[l.id] = l
71+ if gone {
72+ l.close()
73+ }
62 resource.OnDestroy = func() {74 resource.OnDestroy = func() {
63 l.unmap()75 l.unmap()
64 delete(h.server.layers, l.id)76 delete(h.server.layers, l.id)
@@ -130,6 +142,18 @@ func (l *layerSurface) unmap() {
130 l.shell.server.layerChanged()142 l.shell.server.layerChanged()
131 l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})143 l.shell.server.emitContent(ports.SurfaceContent{ID: l.id})
132 }144 }
145+
146+// close tells the client its output is gone; the surface stays unmapped
147+// until the client destroys it.
148+func (l *layerSurface) close() {
149+ l.unmap()
150+ l.output = nil
151+ l.closed = true
152+ if l.resource.Resource.Alive() {
153+ l.resource.SendClosed()
154+ }
155+}
156+
133 func (s *Server) layerChanged() {157 func (s *Server) layerChanged() {
134 list := make([]ports.LayerSurface, 0)158 list := make([]ports.LayerSurface, 0)
135 for _, l := range s.layers {159 for _, l := range s.layers {
@@ -137,6 +161,9 @@ func (s *Server) layerChanged() {
137 continue161 continue
138 }162 }
139 v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard}163 v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard}
164+ if l.output != nil {
165+ v.Output = l.output.name()
166+ }
140 if l.surface.current != nil {167 if l.surface.current != nil {
141 if b := s.buffers[l.surface.current.Resource]; b != nil {168 if b := s.buffers[l.surface.current.Resource]; b != nil {
142 v.Width, v.Height = l.surface.logicalSize(b.width, b.height)169 v.Width, v.Height = l.surface.logicalSize(b.width, b.height)
@@ -152,11 +179,15 @@ func (s *Server) layerChanged() {
152 // the logical output size.179 // the logical output size.
153 func (l *layerSurface) sendConfigure() {180 func (l *layerSurface) sendConfigure() {
154 w, h := l.current.width, l.current.height181 w, h := l.current.width, l.current.height
182+ var ow, oh int
183+ if l.output != nil {
184+ ow, oh = l.output.place.Width, l.output.place.Height
185+ }
155 if w == 0 {186 if w == 0 {
156- w = uint32(l.shell.server.scale.width)187+ w = uint32(ow)
157 }188 }
158 if h == 0 {189 if h == 0 {
159- h = uint32(l.shell.server.scale.height)190+ h = uint32(oh)
160 }191 }
161 l.shell.server.serial++192 l.shell.server.serial++
162 l.resource.SendConfigure(l.shell.server.serial, w, h)193 l.resource.SendConfigure(l.shell.server.serial, w, h)
@@ -170,6 +201,9 @@ func (l *layerSurface) commit(buffer bool) {
170 delete(l.shell.server.layers, l.id)201 delete(l.shell.server.layers, l.id)
171 return202 return
172 }203 }
204+ if l.closed {
205+ return
206+ }
173 p := l.pending207 p := l.pending
174 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {208 if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) {
175 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")209 l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors")
A internal/adapters/wayland/outputs.go +254 -0
@@ -0,0 +1,254 @@
1+package wayland
2+
3+import (
4+ "math"
5+ "strings"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/xdgoutput"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// Outputs. Core sends the global layout (ports.SetOutputs); each output is
14+// one wl_output global, removed when the display is unplugged. Every size
15+// sent to clients (configures, xdg-output, layer configures, pointer
16+// coordinates) is logical. Clients that bind wp_fractional_scale_v1 get the
17+// exact scale of their output in 1/120 steps and draw at physical size
18+// through wp_viewporter; others get wl_output.scale rounded up and the
19+// renderer downscales.
20+
21+// output is one advertised display and the resources clients bound to it.
22+type output struct {
23+ place ports.OutputPlacement
24+ global *server.Global
25+ resources []*wayland.Output
26+ xdgOutputs []*xdgoutput.ZxdgOutputV1
27+}
28+
29+func (o *output) name() string { return o.place.Info.Name }
30+
31+// integerScale is wl_output.scale: the fractional scale rounded up.
32+func integerScale(scale float64) int32 { return int32(math.Ceil(max(scale, 1) - 1e-9)) }
33+
34+func (o *output) description() string {
35+ i := o.place.Info
36+ return strings.Join(strings.Fields(i.Make+" "+i.Model+" "+i.Serial+" ("+i.Name+")"), " ")
37+}
38+
39+// sendAll sends the full wl_output state, then done (v2+).
40+func (o *output) sendAll(r *wayland.Output) {
41+ i := o.place.Info
42+ make, model := i.Make, i.Model
43+ if make == "" {
44+ make, model = "nefertty", "headless"
45+ }
46+ refresh := i.RefreshMilli
47+ if refresh == 0 {
48+ refresh = 60000
49+ }
50+ r.SendGeometry(int32(o.place.X), int32(o.place.Y), int32(i.PhysicalW), int32(i.PhysicalH), 0, make, model, 0)
51+ // The mode stays physical; the scale tells clients how to divide it.
52+ r.SendMode(3, int32(i.Width), int32(i.Height), int32(refresh))
53+ if r.Version() >= 2 {
54+ r.SendScale(integerScale(o.place.Scale))
55+ }
56+ if r.Version() >= 4 {
57+ r.SendName(i.Name)
58+ r.SendDescription(o.description())
59+ }
60+ if r.Version() >= 2 {
61+ r.SendDone()
62+ }
63+}
64+
65+func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) {
66+ x.SendLogicalPosition(int32(o.place.X), int32(o.place.Y))
67+ x.SendLogicalSize(int32(o.place.Width), int32(o.place.Height))
68+ if withName && x.Version() >= 2 {
69+ x.SendName(o.name())
70+ x.SendDescription(o.description())
71+ }
72+}
73+
74+// outputOf returns the output a wl_output resource belongs to, or nil for a
75+// removed one.
76+func (s *Server) outputOf(r *wayland.Output) *output {
77+ if r == nil {
78+ return nil
79+ }
80+ for _, o := range s.outputs {
81+ for _, v := range o.resources {
82+ if v.Resource == r.Resource {
83+ return o
84+ }
85+ }
86+ }
87+ return nil
88+}
89+
90+// outputByName returns the output with that connector, else the focused one,
91+// else the first; nil when there is none.
92+func (s *Server) outputByName(name string) *output {
93+ var first, focused *output
94+ for _, o := range s.outputs {
95+ if o.name() == name {
96+ return o
97+ }
98+ if first == nil {
99+ first = o
100+ }
101+ if o.name() == s.focusedOutput {
102+ focused = o
103+ }
104+ }
105+ if focused != nil {
106+ return focused
107+ }
108+ return first
109+}
110+
111+// addOutput advertises a new wl_output global.
112+func (s *Server) addOutput(p ports.OutputPlacement) {
113+ o := &output{place: p}
114+ g, err := s.display.AddGlobal(wayland.OutputInterface, 4, func(c server.Client, v, id uint32) {
115+ r, err := wayland.NewOutput(c, int32(v), id, outputHandler{})
116+ if err != nil {
117+ return
118+ }
119+ // A client may bind a global just removed: it gets the last state
120+ // and no updates.
121+ o.resources = append(o.resources, r)
122+ r.OnDestroy = func() { o.resources = removeItem(o.resources, r) }
123+ o.sendAll(r)
124+ })
125+ if err != nil {
126+ s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output")
127+ return
128+ }
129+ o.global = g
130+ s.outputs = append(s.outputs, o)
131+ s.log.Info().Str("output", p.Info.Name).Int("x", p.X).Int("w", p.Width).Int("h", p.Height).Float64("scale", p.Scale).Msg("output added")
132+}
133+
134+// setOutputs applies the global layout from core: new outputs are
135+// advertised, gone ones withdrawn (their layer surfaces are closed), and
136+// changed ones resent.
137+func (s *Server) setOutputs(c ports.SetOutputs) {
138+ s.focusedOutput = c.Focused
139+ want := map[string]ports.OutputPlacement{}
140+ for _, p := range c.Outputs {
141+ want[p.Info.Name] = p
142+ }
143+ kept := s.outputs[:0]
144+ for _, o := range s.outputs {
145+ if _, ok := want[o.name()]; ok {
146+ kept = append(kept, o)
147+ continue
148+ }
149+ o.global.Remove()
150+ s.log.Info().Str("output", o.name()).Msg("output removed")
151+ for _, l := range s.layers {
152+ if l.output == o {
153+ l.close()
154+ }
155+ }
156+ for _, surf := range s.surfaces {
157+ surf.leave(o)
158+ }
159+ }
160+ s.outputs = kept
161+ for _, p := range c.Outputs {
162+ o := s.outputByNameExact(p.Info.Name)
163+ if o == nil {
164+ s.addOutput(p)
165+ continue
166+ }
167+ if o.place == p {
168+ continue
169+ }
170+ scaleChanged := o.place.Scale != p.Scale
171+ o.place = p
172+ for _, r := range o.resources {
173+ o.sendAll(r)
174+ }
175+ for _, x := range o.xdgOutputs {
176+ o.sendXDG(x, false)
177+ if x.Version() < 3 {
178+ x.SendDone()
179+ }
180+ }
181+ if scaleChanged {
182+ s.log.Info().Str("output", p.Info.Name).Float64("scale", p.Scale).Int("w", p.Width).Int("h", p.Height).Msg("output scale")
183+ }
184+ }
185+ // Surfaces follow the scale of their output.
186+ for _, surf := range s.surfaces {
187+ surf.sendScale()
188+ }
189+ // Layer surfaces sized from the output follow its new logical size.
190+ adopted := false
191+ for _, l := range s.layers {
192+ if l.output == nil && !l.closed {
193+ // Created before any output: it takes the focused one.
194+ if l.output = s.outputByName(""); l.output != nil && l.configured {
195+ l.sendConfigure()
196+ adopted = adopted || l.mapped
197+ continue
198+ }
199+ }
200+ // Uses committed state only: pending requests wait for the client's commit.
201+ if l.configured && (l.current.width == 0 || l.current.height == 0) {
202+ l.sendConfigure()
203+ }
204+ }
205+ if adopted {
206+ s.layerChanged()
207+ }
208+}
209+
210+func (s *Server) outputByNameExact(name string) *output {
211+ for _, o := range s.outputs {
212+ if o.name() == name {
213+ return o
214+ }
215+ }
216+ return nil
217+}
218+
219+type outputHandler struct{}
220+
221+func (outputHandler) Release(*wayland.Output) {}
222+
223+func registerXDGOutput(d *server.Display, s *Server) error {
224+ return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
225+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})
226+ })
227+}
228+
229+type xdgOutputManager struct{ server *Server }
230+
231+func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
232+func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, wl *wayland.Output) {
233+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
234+ if err != nil {
235+ return
236+ }
237+ o := m.server.outputOf(wl)
238+ if o == nil {
239+ // Removed output: the object stays inert.
240+ return
241+ }
242+ o.xdgOutputs = append(o.xdgOutputs, x)
243+ x.OnDestroy = func() { o.xdgOutputs = removeItem(o.xdgOutputs, x) }
244+ o.sendXDG(x, true)
245+ if r.Version() < 3 || wl.Version() < 2 {
246+ x.SendDone()
247+ } else {
248+ wl.SendDone()
249+ }
250+}
251+
252+type xdgOutput struct{}
253+
254+func (xdgOutput) Destroy(*xdgoutput.ZxdgOutputV1) {}
A internal/adapters/wayland/outputs_test.go +108 -0
@@ -0,0 +1,108 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+ "github.com/bnema/purego-libwayland/protocol/wayland"
9+ "github.com/bnema/purego-libwayland/protocol/wlrlayershell"
10+ "github.com/bnema/wlturbo"
11+)
12+
13+var second = ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 1280, Height: 1024}, X: 1920, Width: 1280, Height: 1024, Scale: 1}
14+
15+// outputCount counts wl_output globals the client sees.
16+func outputCount(c *wlturbo.Display) int {
17+ n := 0
18+ for _, g := range c.Registry().GetGlobals() {
19+ if g.Interface == "wl_output" {
20+ n++
21+ }
22+ }
23+ return n
24+}
25+
26+// waitOutputs round-trips until the client sees n wl_output globals.
27+func waitOutputs(t *testing.T, c *wlturbo.Display, n int) {
28+ t.Helper()
29+ for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
30+ if err := c.Roundtrip(); err != nil {
31+ t.Fatal(err)
32+ }
33+ if outputCount(c) == n {
34+ return
35+ }
36+ time.Sleep(5 * time.Millisecond)
37+ }
38+ t.Fatalf("wl_output globals: %d, want %d", outputCount(c), n)
39+}
40+
41+type closedProxy struct {
42+ wlturbo.BaseProxy
43+ configured chan [3]uint32
44+ closed chan struct{}
45+}
46+
47+func (p *closedProxy) Dispatch(e *wlturbo.Event) {
48+ switch e.Opcode {
49+ case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure):
50+ p.configured <- [3]uint32{e.Uint32(), e.Uint32(), e.Uint32()}
51+ case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed):
52+ close(p.closed)
53+ }
54+}
55+
56+func TestOutputHotplugAndLayerOutput(t *testing.T) {
57+ s, _, commands, dir := lifecycleServer(t)
58+ c := protocolClient(t, s, dir)
59+ if outputCount(c) != 1 {
60+ t.Fatal(outputCount(c))
61+ }
62+ commands <- ports.SetOutputs{Outputs: ports.Layout{testOutputs[0], second}, Focused: "HEADLESS-1"}
63+ waitOutputs(t, c, 2)
64+ // A layer surface on the second output is sized from it.
65+ var name uint32
66+ for n, g := range c.Registry().GetGlobals() {
67+ if g.Interface == "wl_output" && (name == 0 || n > name) {
68+ name = n
69+ }
70+ }
71+ out, err := c.Registry().BindID(name, "wl_output", 4)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ registerProtocol(t, c, out)
76+ comp := bindProtocol(t, c, "wl_compositor")
77+ shell := bindProtocol(t, c, "zwlr_layer_shell_v1")
78+ surf := c.AllocateID()
79+ requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf)
80+ registerProtocol(t, c, surf)
81+ layer := c.AllocateID()
82+ proxy := &closedProxy{configured: make(chan [3]uint32, 2), closed: make(chan struct{})}
83+ proxy.SetID(layer)
84+ c.Context().Register(proxy)
85+ requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerTop), "bar")
86+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(13))
87+ requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(30))
88+ requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
89+ if err := c.Roundtrip(); err != nil {
90+ t.Fatal(err)
91+ }
92+ select {
93+ case cfg := <-proxy.configured:
94+ if cfg[1] != 1280 {
95+ t.Fatalf("configure %v", cfg)
96+ }
97+ case <-time.After(2 * time.Second):
98+ t.Fatal("configure timeout")
99+ }
100+ // Unplugged: the global goes away and the layer surface is closed.
101+ commands <- ports.SetOutputs{Outputs: ports.Layout{testOutputs[0]}, Focused: "HEADLESS-1"}
102+ waitOutputs(t, c, 1)
103+ select {
104+ case <-proxy.closed:
105+ case <-time.After(2 * time.Second):
106+ t.Fatal("layer not closed")
107+ }
108+}
M internal/adapters/wayland/scale.go +65 -54
@@ -3,72 +3,79 @@ package wayland
3 import (3 import (
4 "math"4 "math"
5 5
6- "github.com/bnema/nefertty/internal/ports"
7 "github.com/bnema/purego-libwayland/protocol/fractionalscale"6 "github.com/bnema/purego-libwayland/protocol/fractionalscale"
8 "github.com/bnema/purego-libwayland/protocol/viewporter"7 "github.com/bnema/purego-libwayland/protocol/viewporter"
9 "github.com/bnema/purego-libwayland/protocol/wayland"8 "github.com/bnema/purego-libwayland/protocol/wayland"
10- "github.com/bnema/purego-libwayland/protocol/xdgoutput"
11 "github.com/bnema/purego-libwayland/server"9 "github.com/bnema/purego-libwayland/server"
12 )10 )
13 11
14-// Output scale (HiDPI). Clients that bind wp_fractional_scale_v1 get the exact12+// Surfaces follow the output of their window or layer (the focused output
15-// scale in 1/120 steps and draw at physical size through wp_viewporter. Others13+// until they are shown): wl_surface.enter/leave, preferred buffer scale and
16-// get wl_output.scale rounded up, draw at that integer, and the renderer14+// fractional scale all come from it.
17-// downscales. Every size sent to clients (configures, xdg-output, layer15+
18-// configures, pointer coordinates) stays logical.16+// outputOfSurface is the output a surface is on: its window's configured
19-17+// output, its layer output, or the focused one.
20-// scaleState is what clients were last told about the output.18+func (s *Server) outputOfSurface(surf *surface) *output {
21-type scaleState struct {19+ name := ""
22- scale float6420+ switch {
23- width, height int // logical21+ case surf.xdg != nil && surf.xdg.window != nil:
24- outputs []*wayland.Output22+ name = surf.xdg.window.last.Output
25- xdgOutputs []*xdgoutput.ZxdgOutputV123+ case surf.layer != nil && surf.layer.output != nil:
26- fractions map[*surface]*fractionalscale.WpFractionalScaleV124+ return surf.layer.output
25+ }
26+ return s.outputByName(name)
27 }27 }
28 28
29-// integerScale is wl_output.scale: the fractional scale rounded up.29+// sendScale tells the surface about its output: enter/leave, the integer
30-func (st *scaleState) integerScale() int32 { return int32(math.Ceil(st.scale - 1e-9)) }30+// buffer scale (wl_surface v6+) and the fractional scale.
31-31+func (surf *surface) sendScale() {
32-func (s *Server) setOutputScale(c ports.SetOutputScale) {32+ s := surf.server
33- st := &s.scale33+ if surf.destroyed || surf.wl == nil || !surf.wl.Resource.Alive() {
34- scaleChanged := c.Scale != st.scale34+ return
35- st.scale, st.width, st.height = c.Scale, c.Width, c.Height
36- s.log.Info().Float64("scale", c.Scale).Int("w", c.Width).Int("h", c.Height).Msg("output scale")
37- for _, x := range st.xdgOutputs {
38- x.SendLogicalSize(int32(c.Width), int32(c.Height))
39- if x.Version() < 3 {
40- x.SendDone()
41- }
42- }
43- for _, o := range st.outputs {
44- if o.Version() >= 2 {
45- o.SendScale(st.integerScale())
46- o.SendDone()
47- }
48 }35 }
49- if scaleChanged {36+ o := s.outputOfSurface(surf)
50- for _, surf := range s.surfaces {37+ if o != surf.on {
51- s.sendSurfaceScale(surf.wl)38+ if surf.on != nil {
39+ surf.leave(surf.on)
52 }40 }
53- for surf, f := range st.fractions {41+ surf.on = o
54- if !surf.destroyed {42+ if o != nil {
55- f.SendPreferredScale(uint32(math.Round(st.scale * 120)))43+ for _, r := range o.resources {
44+ if r.Client() == surf.wl.Client() {
45+ surf.wl.SendEnter(r)
46+ }
56 }47 }
57 }48 }
58 }49 }
59- // Layer surfaces sized from the output follow its new logical size.50+ scale := 1.0
60- for _, l := range s.layers {51+ if o != nil {
61- // Uses committed state only: pending requests wait for the client's commit.52+ scale = o.place.Scale
62- if l.configured && (l.current.width == 0 || l.current.height == 0) {53+ }
63- l.sendConfigure()54+ if scale == surf.scale {
64- }55+ return
56+ }
57+ surf.scale = scale
58+ if surf.wl.Version() >= 6 {
59+ surf.wl.SendPreferredBufferScale(integerScale(scale))
60+ }
61+ if f := s.fractions[surf]; f != nil {
62+ f.SendPreferredScale(uint32(math.Round(scale * 120)))
65 }63 }
66 }64 }
67 65
68-// sendSurfaceScale tells a wl_surface v6+ which integer buffer scale to use.66+// leave sends wl_surface.leave for o when the surface is on it.
69-func (s *Server) sendSurfaceScale(w *wayland.Surface) {67+func (surf *surface) leave(o *output) {
70- if w != nil && w.Version() >= 6 {68+ if surf.on != o || o == nil {
71- w.SendPreferredBufferScale(s.scale.integerScale())69+ return
70+ }
71+ surf.on = nil
72+ if surf.destroyed || !surf.wl.Resource.Alive() {
73+ return
74+ }
75+ for _, r := range o.resources {
76+ if r.Client() == surf.wl.Client() && r.Resource.Alive() {
77+ surf.wl.SendLeave(r)
78+ }
72 }79 }
73 }80 }
74 81
@@ -92,7 +99,7 @@ func (m fractionManager) GetFractionalScale(r *fractionalscale.WpFractionalScale
92 if surf == nil {99 if surf == nil {
93 return100 return
94 }101 }
95- if _, dup := s.scale.fractions[surf]; dup {102+ if _, dup := s.fractions[surf]; dup {
96 r.PostError(uint32(fractionalscale.WpFractionalScaleManagerV1ErrorFractionalScaleExists), "fractional scale already exists")103 r.PostError(uint32(fractionalscale.WpFractionalScaleManagerV1ErrorFractionalScaleExists), "fractional scale already exists")
97 return104 return
98 }105 }
@@ -100,9 +107,13 @@ func (m fractionManager) GetFractionalScale(r *fractionalscale.WpFractionalScale
100 if err != nil {107 if err != nil {
101 return108 return
102 }109 }
103- s.scale.fractions[surf] = f110+ s.fractions[surf] = f
104- f.OnDestroy = func() { delete(s.scale.fractions, surf) }111+ f.OnDestroy = func() { delete(s.fractions, surf) }
105- f.SendPreferredScale(uint32(math.Round(s.scale.scale * 120)))112+ scale := 1.0
113+ if o := s.outputOfSurface(surf); o != nil {
114+ scale = o.place.Scale
115+ }
116+ f.SendPreferredScale(uint32(math.Round(scale * 120)))
106 }117 }
107 118
108 type fraction struct{}119 type fraction struct{}
M internal/adapters/wayland/scale_test.go +2 -2
@@ -44,7 +44,7 @@ func contentServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.Cl
44 events := make(chan ports.ClientEvent, 16)44 events := make(chan ports.ClientEvent, 16)
45 commands := make(chan ports.ClientCommand, 16)45 commands := make(chan ports.ClientCommand, 16)
46 contents := make(chan ports.SurfaceContent, 16)46 contents := make(chan ports.SurfaceContent, 16)
47- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))47+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands, Contents: contents}, logging.For(context.Background(), "wayland"))
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
@@ -92,7 +92,7 @@ func TestFractionalScaleAndViewport(t *testing.T) {
92 t.Fatalf("initial scale %d", v)92 t.Fatalf("initial scale %d", v)
93 }93 }
94 // Core switches to 1.5: the surface gets 180/120.94 // Core switches to 1.5: the surface gets 180/120.
95- commands <- ports.SetOutputScale{Scale: 1.5, Width: 1280, Height: 720}95+ commands <- ports.SetOutputs{Outputs: ports.Layout{{Info: testOutputs[0].Info, Width: 1280, Height: 720, Scale: 1.5}}}
96 // Events are read by Roundtrip; the command is applied asynchronously.96 // Events are read by Roundtrip; the command is applied asynchronously.
97 var got uint3297 var got uint32
98 for deadline := time.Now().Add(2 * time.Second); got == 0 && time.Now().Before(deadline); {98 for deadline := time.Now().Add(2 * time.Second); got == 0 && time.Now().Before(deadline); {
M internal/adapters/wayland/server.go +18 -31
@@ -16,34 +16,13 @@ import (
16 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
17 )17 )
18 18
19+// Options configures the server. Outputs is the initial layout; core
20+// replaces it with ports.SetOutputs.
19 type Options struct {21 type Options struct {
20- RuntimeDir string22+ RuntimeDir string
21- OutputWidth, OutputHeight int23+ Outputs ports.Layout
22- Output OutputInfo24+ Keymap string
23- Keymap string25+ RepeatRate, RepeatDelay int
24- RepeatRate, RepeatDelay int
25-}
26-
27-// OutputInfo describes the advertised wl_output. Zero values fall back to the headless output.
28-type OutputInfo struct {
29- Name, Description string
30- Make, Model string
31- RefreshMilli int
32- PhysicalW, PhysicalH int
33-}
34-
35-func (o Options) output() OutputInfo {
36- i := o.Output
37- if i.Name == "" {
38- i.Name, i.Description = "HEADLESS-1", "NeferTTY headless output"
39- }
40- if i.Make == "" {
41- i.Make, i.Model = "nefertty", "headless"
42- }
43- if i.RefreshMilli == 0 {
44- i.RefreshMilli = 60000
45- }
46- return i
47 }26 }
48 27
49 // Channels carries client notifications and commands. Events may be unbuffered.28 // Channels carries client notifications and commands. Events may be unbuffered.
@@ -93,7 +72,9 @@ type Server struct {
93 contentSeq map[ports.WindowID]uint6472 contentSeq map[ports.WindowID]uint64
94 contentNotify chan struct{}73 contentNotify chan struct{}
95 contentReady chan struct{}74 contentReady chan struct{}
96- scale scaleState75+ outputs []*output
76+ focusedOutput string
77+ fractions map[*surface]*fractionalscale.WpFractionalScaleV1
97 }78 }
98 79
99 func removeItem[T comparable](list []T, v T) []T {80 func removeItem[T comparable](list []T, v T) []T {
@@ -128,7 +109,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
128 return nil, err109 return nil, err
129 }110 }
130 s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}111 s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]*buffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay}
131- s.scale = scaleState{scale: 1, width: opts.OutputWidth, height: opts.OutputHeight, fractions: map[*surface]*fractionalscale.WpFractionalScaleV1{}}112+ s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
132 if opts.Keymap != "" {113 if opts.Keymap != "" {
133 fd, size, e := keymapFile(opts.Keymap)114 fd, size, e := keymapFile(opts.Keymap)
134 if e != nil {115 if e != nil {
@@ -149,6 +130,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
149 d.Close()130 d.Close()
150 return nil, err131 return nil, err
151 }132 }
133+ for _, p := range opts.Outputs {
134+ s.addOutput(p)
135+ }
152 return s, nil136 return s, nil
153 }137 }
154 func (s *Server) SocketName() string { return s.name }138 func (s *Server) SocketName() string { return s.name }
@@ -420,8 +404,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
420 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")404 s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure")
421 w.last, w.hasLast = c, true405 w.last, w.hasLast = c, true
422 w.sendConfigure()406 w.sendConfigure()
423- case ports.SetOutputScale:407+ if surf := w.xdg.surface; surf != nil && c.Output != "" {
424- s.setOutputScale(c)408+ surf.sendScale()
409+ }
410+ case ports.SetOutputs:
411+ s.setOutputs(c)
425 case ports.SlotsPending:412 case ports.SlotsPending:
426 s.slotsPending = c.Pending413 s.slotsPending = c.Pending
427 case ports.CloseWindow:414 case ports.CloseWindow:
M internal/adapters/wayland/server_test.go +8 -5
@@ -21,7 +21,7 @@ func TestWaylandInfo(t *testing.T) {
21 t.Skip("wayland-info not installed")21 t.Skip("wayland-info not installed")
22 }22 }
23 dir := t.TempDir()23 dir := t.TempDir()
24- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{}, logging.For(context.Background(), "wayland"))24+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
25 if err != nil {25 if err != nil {
26 t.Fatal(err)26 t.Fatal(err)
27 }27 }
@@ -63,7 +63,7 @@ func TestWestonFrames(t *testing.T) {
63 t.Skip("weston-simple-shm not installed")63 t.Skip("weston-simple-shm not installed")
64 }64 }
65 dir := t.TempDir()65 dir := t.TempDir()
66- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{}, logging.For(context.Background(), "wayland"))66+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
67 if err != nil {67 if err != nil {
68 t.Fatal(err)68 t.Fatal(err)
69 }69 }
@@ -125,7 +125,7 @@ func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.
125 dir := t.TempDir()125 dir := t.TempDir()
126 events := make(chan ports.ClientEvent, 16)126 events := make(chan ports.ClientEvent, 16)
127 commands := make(chan ports.ClientCommand, 16)127 commands := make(chan ports.ClientCommand, 16)
128- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))128+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
129 if err != nil {129 if err != nil {
130 t.Fatal(err)130 t.Fatal(err)
131 }131 }
@@ -318,7 +318,7 @@ func TestUnbufferedEventsDoNotBlockDisplay(t *testing.T) {
318 }318 }
319 dir := t.TempDir()319 dir := t.TempDir()
320 events := make(chan ports.ClientEvent)320 events := make(chan ports.ClientEvent)
321- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events}, logging.For(context.Background(), "wayland"))321+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events}, logging.For(context.Background(), "wayland"))
322 if err != nil {322 if err != nil {
323 t.Fatal(err)323 t.Fatal(err)
324 }324 }
@@ -373,7 +373,7 @@ func TestWestonContents(t *testing.T) {
373 dir := t.TempDir()373 dir := t.TempDir()
374 events := make(chan ports.ClientEvent, 16)374 events := make(chan ports.ClientEvent, 16)
375 contents := make(chan ports.SurfaceContent, 16)375 contents := make(chan ports.SurfaceContent, 16)
376- s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))376+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))
377 if err != nil {377 if err != nil {
378 t.Fatal(err)378 t.Fatal(err)
379 }379 }
@@ -420,3 +420,6 @@ func TestWestonContents(t *testing.T) {
420 }420 }
421 }421 }
422 }422 }
423+
424+// testOutputs is the 1920x1080 output tests start with.
425+var testOutputs = ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, Width: 1920, Height: 1080, Scale: 1}}
M internal/adapters/wayland/surface.go +3 -0
@@ -32,6 +32,9 @@ type surface struct {
32 role func(bool)32 role func(bool)
33 destroyed bool33 destroyed bool
34 released bool34 released bool
35+ // on is the output the surface entered; scale is the last scale sent.
36+ on *output
37+ scale float64
35 }38 }
36 39
37 func (s *surface) Destroy(*wayland.Surface) {40 func (s *surface) Destroy(*wayland.Surface) {
D internal/adapters/wayland/xdgoutput.go +0 -43
@@ -1,43 +0,0 @@
1-package wayland
2-
3-import (
4- "github.com/bnema/purego-libwayland/protocol/wayland"
5- "github.com/bnema/purego-libwayland/protocol/xdgoutput"
6- "github.com/bnema/purego-libwayland/server"
7-)
8-
9-type xdgOutputManager struct {
10- server *Server
11- info OutputInfo
12-}
13-
14-func registerXDGOutput(d *server.Display, o Options, s *Server) error {
15- return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
16- _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s, o.output()})
17- })
18-}
19-func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
20-func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, output *wayland.Output) {
21- x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
22- if err != nil {
23- return
24- }
25- st := &m.server.scale
26- st.xdgOutputs = append(st.xdgOutputs, x)
27- x.OnDestroy = func() { st.xdgOutputs = removeItem(st.xdgOutputs, x) }
28- x.SendLogicalPosition(0, 0)
29- x.SendLogicalSize(int32(st.width), int32(st.height))
30- if r.Version() >= 2 {
31- x.SendName(m.info.Name)
32- x.SendDescription(m.info.Description)
33- }
34- if r.Version() < 3 || output == nil || output.Version() < 2 {
35- x.SendDone()
36- } else {
37- output.SendDone()
38- }
39-}
40-
41-type xdgOutput struct{}
42-
43-func (xdgOutput) Destroy(*xdgoutput.ZxdgOutputV1) {}
M internal/app/app.go +47 -53
@@ -12,7 +12,7 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/nefertty/internal/adapters/config"14 "github.com/bnema/nefertty/internal/adapters/config"
15- "github.com/bnema/nefertty/internal/adapters/headless"15+ "github.com/bnema/nefertty/internal/adapters/drm"
16 "github.com/bnema/nefertty/internal/adapters/headlessinput"16 "github.com/bnema/nefertty/internal/adapters/headlessinput"
17 "github.com/bnema/nefertty/internal/adapters/launcher"17 "github.com/bnema/nefertty/internal/adapters/launcher"
18 "github.com/bnema/nefertty/internal/adapters/libinput"18 "github.com/bnema/nefertty/internal/adapters/libinput"
@@ -32,11 +32,13 @@ type Options struct {
32 Timeout time.Duration32 Timeout time.Duration
33 NoTerminal bool33 NoTerminal bool
34 ScreenshotDir string34 ScreenshotDir string
35- // Width and Height size the headless output; 0 means 1920x1080.35+ // Sizes are the headless outputs (width, height), left to right; empty
36- Width, Height int36+ // means one 1920x1080 output. With several outputs, screenshots go to
37+ // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...).
38+ Sizes [][2]int
37 // Run closes Script on shutdown; the reader goroutine exits after Close.39 // Run closes Script on shutdown; the reader goroutine exits after Close.
38 Script io.ReadCloser40 Script io.ReadCloser
39- testScenes chan<- ports.Scene41+ testScenes chan<- []ports.Scene
40 }42 }
41 43
42 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }44 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -51,11 +53,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
51 ctx, cancel = context.WithCancel(ctx)53 ctx, cancel = context.WithCancel(ctx)
52 }54 }
53 defer cancel()55 defer cancel()
54- width, height := opts.Width, opts.Height56+ sizes := opts.Sizes
55- if width <= 0 || height <= 0 {57+ if len(sizes) == 0 {
56- width, height = 1920, 108058+ sizes = [][2]int{{1920, 1080}}
57 }59 }
58- var outInfo wayland.OutputInfo
59 var hw *drmBackend60 var hw *drmBackend
60 if opts.Backend == "drm" {61 if opts.Backend == "drm" {
61 var err error62 var err error
@@ -63,9 +64,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
63 return err64 return err
64 }65 }
65 defer hw.close()66 defer hw.close()
66- width, height = hw.out.Width(), hw.out.Height()
67- outInfo = hw.outputInfo()
68- hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
69 }67 }
70 client := make(chan ports.ClientEvent, 32)68 client := make(chan ports.ClientEvent, 32)
71 input := make(chan ports.InputEvent, 32)69 input := make(chan ports.InputEvent, 32)
@@ -73,27 +71,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
73 configChanges := make(chan ports.ConfigChanged, 8)71 configChanges := make(chan ports.ConfigChanged, 8)
74 commands := make(chan ports.ClientCommand, 32)72 commands := make(chan ports.ClientCommand, 32)
75 spawn := make(chan ports.SpawnRequest, 32)73 spawn := make(chan ports.SpawnRequest, 32)
76- scenes := make(chan ports.Scene, 1)74+ scenes := make(chan []ports.Scene, 1)
75+ layouts := make(chan ports.Layout, 1)
77 configErrors := make(chan error, 8)76 configErrors := make(chan error, 8)
78- renderScenes := make(chan ports.Scene, 1)77+ renderScenes := make(chan []ports.Scene, 1)
79 contents := make(chan ports.SurfaceContent, 64)78 contents := make(chan ports.SurfaceContent, 64)
80- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors}79+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors}
81 c, err := core.New(opts.Config, ch)80 c, err := core.New(opts.Config, ch)
82 if err != nil {81 if err != nil {
83 return err82 return err
84 }83 }
85- outName := outInfo.Name
86- if outName == "" {
87- outName = "HEADLESS-1"
88- }
89- output <- ports.OutputMode{Width: width, Height: height, Name: outName}
90 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})84 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
91 if err != nil {85 if err != nil {
92 return err86 return err
93 }87 }
94 keymap := km.String()88 keymap := km.String()
95 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")89 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
96- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Output: outInfo, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))90+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
97 if err != nil {91 if err != nil {
98 km.Close()92 km.Close()
99 return err93 return err
@@ -125,22 +119,24 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
125 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))119 relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config"))
126 }()120 }()
127 script := make(chan string)121 script := make(chan string)
128- // The software cursor starts centred, like libinput's pointer.122+ curs := newCursors()
129- softCursor := &headless.Cursor{}
130- softCursor.Move(float64(width)/2, float64(height)/2)
131 go func() {123 go func() {
132 defer workers.Done()124 defer workers.Done()
133 if hw != nil {125 if hw != nil {
126+ // Core sends the first layout once DRM reports the outputs.
127+ var layout ports.Layout
128+ select {
129+ case layout = <-layouts:
130+ case <-ctx.Done():
131+ km.Close()
132+ return
133+ }
134 done <- safe("input", func() error {134 done <- safe("input", func() error {
135- var move func(x, y float64)135+ return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input")}, input)
136- if cur := hw.out.Cursor(); cur != nil {
137- move = cur.Move
138- }
139- return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Width: width, Height: height, Active: hw.inputActive, MoveCursor: move, Log: logging.For(ctx, "input")}, input)
140 })136 })
141 return137 return
142 }138 }
143- _ = headlessinput.Run(ctx, km, keymaps, script, input, softCursor.Move, logging.For(ctx, "input"))139+ _ = headlessinput.Run(ctx, km, keymaps, script, input, layouts, curs.move, logging.For(ctx, "input"))
144 }()140 }()
145 if opts.Script != nil {141 if opts.Script != nil {
146 go func() { <-ctx.Done(); _ = opts.Script.Close() }()142 go func() { <-ctx.Done(); _ = opts.Script.Close() }()
@@ -168,25 +164,21 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
168 164
169 go func() {165 go func() {
170 defer workers.Done()166 defer workers.Done()
171- if hw != nil {167+ newRenderer := func(w, h int) (ports.Renderer, error) {
172- done <- safe("output", func() error {
173- return hw.out.Run(ctx, func(w, h int) (ports.Renderer, error) {
174- r, err := vulkan.New(w, h)
175- if err != nil {
176- return nil, err
177- }
178- return r, nil
179- }, loadCursor, hw.outputActive, renderScenes, contents)
180- })
181- return
182- }
183- done <- headless.Run(ctx, headless.Options{Cursor: softCursor, LoadCursor: loadCursor, Width: width, Height: height, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (ports.Renderer, error) {
184 r, err := vulkan.New(w, h)168 r, err := vulkan.New(w, h)
185 if err != nil {169 if err != nil {
186 return nil, err170 return nil, err
187 }171 }
188 return r, nil172 return r, nil
189- }}, renderScenes, contents)173+ }
174+ if hw != nil {
175+ done <- safe("output", func() error {
176+ want := func() drm.Want { return wantFromConfig(opts.Config.Outputs) }
177+ return hw.runOutputs(ctx, want, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "drm"))
178+ })
179+ return
180+ }
181+ done <- runHeadless(ctx, sizes, opts.ScreenshotDir, output, renderScenes, contents, curs, newRenderer, logging.For(ctx, "render"))
190 }()182 }()
191 183
192 if hw != nil {184 if hw != nil {
@@ -280,38 +272,40 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
280 }272 }
281 }273 }
282 274
283-func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) {275+func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
284 log := logging.For(ctx, "core")276 log := logging.For(ctx, "core")
285 configLog := logging.For(ctx, "config")277 configLog := logging.For(ctx, "config")
286 for {278 for {
287 select {279 select {
288 case <-ctx.Done():280 case <-ctx.Done():
289 return281 return
290- case s := <-scenes:282+ case set := <-scenes:
291 if tap != nil {283 if tap != nil {
292 select {284 select {
293- case tap <- s:285+ case tap <- set:
294 default:286 default:
295 }287 }
296 }288 }
297 select {289 select {
298- case renderScenes <- s:290+ case renderScenes <- set:
299 default:291 default:
300 select {292 select {
301 case <-renderScenes:293 case <-renderScenes:
302 default:294 default:
303 }295 }
304 select {296 select {
305- case renderScenes <- s:297+ case renderScenes <- set:
306 default:298 default:
307 }299 }
308 }300 }
309- ev := log.Debug().Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)301+ for _, s := range set {
310- rects := make([]string, 0, len(s.Windows))302+ ev := log.Debug().Str("output", s.Output).Uint64("seq", s.Seq).Int("out_w", s.OutputWidth).Int("out_h", s.OutputHeight).Float64("scale", s.Scale)
311- for _, w := range s.Windows {303+ rects := make([]string, 0, len(s.Windows))
312- rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))304+ for _, w := range s.Windows {
305+ rects = append(rects, fmt.Sprintf("%d:%d,%d %dx%d", w.ID, w.Rect.X, w.Rect.Y, w.Rect.W, w.Rect.H))
306+ }
307+ ev.Strs("windows", rects).Msg("scene")
313 }308 }
314- ev.Strs("windows", rects).Msg("scene")
315 case err := <-configErrors:309 case err := <-configErrors:
316 configLog.Warn().Err(err).Msg("config rejected")310 configLog.Warn().Err(err).Msg("config rejected")
317 }311 }
M internal/app/app_test.go +30 -4
@@ -108,7 +108,7 @@ func TestHeadlessSpawnClose(t *testing.T) {
108 t.Setenv("XDG_STATE_HOME", t.TempDir())108 t.Setenv("XDG_STATE_HOME", t.TempDir())
109 cfg := config.Defaults()109 cfg := config.Defaults()
110 cfg.Terminal.Command = []string{"weston-simple-shm"}110 cfg.Terminal.Command = []string{"weston-simple-shm"}
111- scenes := make(chan ports.Scene, 64)111+ scenes := make(chan []ports.Scene, 64)
112 ctx, cancel := context.WithCancel(context.Background())112 ctx, cancel := context.WithCancel(context.Background())
113 defer cancel()113 defer cancel()
114 inputReady := make(chan chan<- ports.InputEvent, 1)114 inputReady := make(chan chan<- ports.InputEvent, 1)
@@ -140,7 +140,8 @@ func TestHeadlessSpawnClose(t *testing.T) {
140 defer timer.Stop()140 defer timer.Stop()
141 for {141 for {
142 select {142 select {
143- case s := <-scenes:143+ case set := <-scenes:
144+ s := set[0]
144 if len(s.Windows) == count {145 if len(s.Windows) == count {
145 // A lone column fills the 1920 output (default zero gaps).146 // A lone column fills the 1920 output (default zero gaps).
146 if count == 1 && s.Windows[0].Rect.W != 1920 {147 if count == 1 && s.Windows[0].Rect.W != 1920 {
@@ -221,7 +222,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
221 t.Setenv("XDG_STATE_HOME", t.TempDir())222 t.Setenv("XDG_STATE_HOME", t.TempDir())
222 cfg := config.Defaults()223 cfg := config.Defaults()
223 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}224 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
224- scenes := make(chan ports.Scene, 128)225+ scenes := make(chan []ports.Scene, 128)
225 err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})226 err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
226 if err != nil {227 if err != nil {
227 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {228 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
@@ -231,7 +232,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
231 }232 }
232 two, focused := false, false233 two, focused := false, false
233 for len(scenes) > 0 {234 for len(scenes) > 0 {
234- s := <-scenes235+ s := (<-scenes)[0]
235 if len(s.Windows) == 2 {236 if len(s.Windows) == 2 {
236 two = true237 two = true
237 for _, w := range s.Windows {238 for _, w := range s.Windows {
@@ -310,3 +311,28 @@ func TestRelayConfigKeyboard(t *testing.T) {
310 t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)311 t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
311 }312 }
312 }313 }
314+
315+func TestHeadlessTwoOutputs(t *testing.T) {
316+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
317+ scenes := make(chan []ports.Scene, 64)
318+ shots := t.TempDir()
319+ err := Run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})
320+ if err != nil {
321+ if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
322+ t.Skipf("Vulkan unavailable: %v", err)
323+ }
324+ t.Fatal(err)
325+ }
326+ var last []ports.Scene
327+ for len(scenes) > 0 {
328+ last = <-scenes
329+ }
330+ if len(last) != 2 || last[0].Output != "HEADLESS-1" || last[1].OutputWidth != 320 {
331+ t.Fatalf("%+v", last)
332+ }
333+ for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
334+ if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil {
335+ t.Fatal(err)
336+ }
337+ }
338+}
M internal/app/drm.go +173 -56
@@ -7,24 +7,27 @@ import (
7 "path/filepath"7 "path/filepath"
8 "runtime/debug"8 "runtime/debug"
9 "sort"9 "sort"
10- "strings"10+ "sync"
11 11
12 "github.com/bnema/nefertty/internal/adapters/config"12 "github.com/bnema/nefertty/internal/adapters/config"
13 "github.com/bnema/nefertty/internal/adapters/drm"13 "github.com/bnema/nefertty/internal/adapters/drm"
14 "github.com/bnema/nefertty/internal/adapters/seat"14 "github.com/bnema/nefertty/internal/adapters/seat"
15- "github.com/bnema/nefertty/internal/adapters/wayland"
16 "github.com/bnema/nefertty/internal/logging"15 "github.com/bnema/nefertty/internal/logging"
17 "github.com/bnema/nefertty/internal/ports"16 "github.com/bnema/nefertty/internal/ports"
17+ "github.com/bnema/zerowrap"
18 )18 )
19 19
20 var errTimeout = errors.New("timeout")20 var errTimeout = errors.New("timeout")
21 21
22+// drmBackend is the seat and the cards with at least one usable output.
22 type drmBackend struct {23 type drmBackend struct {
23- seat *seat.Seat24+ seat *seat.Seat
24- out *drm.Output25+ cards []*drmCard
25- fd int26+}
26 27
27- inputActive, outputActive <-chan bool28+type drmCard struct {
29+ *drm.Card
30+ fd int
28 }31 }
29 32
30 // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.33 // safe turns a panic in a hardware goroutine into an error, so the TTY is still restored.
@@ -37,7 +40,8 @@ func safe(name string, fn func() error) (err error) {
37 return fn()40 return fn()
38 }41 }
39 42
40-// openDRM opens the seat and the first card with a connected display.43+// openDRM opens the seat and every card with a connected display. Outputs
44+// are found by the first Scan of each card.
41 func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {45 func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
42 log := logging.For(ctx, "drm")46 log := logging.For(ctx, "drm")
43 s, err := seat.Open(ctx, logging.For(ctx, "seat"))47 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
@@ -46,74 +50,187 @@ func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, er
46 return nil, fmt.Errorf("open seat: %w", err)50 return nil, fmt.Errorf("open seat: %w", err)
47 }51 }
48 want := wantFromConfig(outputs)52 want := wantFromConfig(outputs)
49- cards, _ := filepath.Glob("/dev/dri/card[0-9]*")53+ paths, _ := filepath.Glob("/dev/dri/card[0-9]*")
50- sort.Strings(cards)54+ sort.Strings(paths)
55+ b := &drmBackend{seat: s}
51 var errs []error56 var errs []error
52- // First pass: the configured output on any card; second: any usable output.57+ for _, path := range paths {
53- passes := []drm.Want{want}58+ fd, err := s.OpenDevice(path)
54- if want.Name != "" {59+ if err != nil {
55- strict := want60+ errs = append(errs, err)
56- strict.Strict = true61+ continue
57- passes = []drm.Want{strict, want}62+ }
58- }63+ card, err := drm.OpenCard(fd, path, want, log)
59- for _, w := range passes {64+ if err != nil {
60- for _, card := range cards {65+ log.Warn().Err(err).Str("card", path).Msg("card unusable")
61- fd, err := s.OpenDevice(card)66+ errs = append(errs, err)
62- if err != nil {67+ s.CloseDevice(fd)
63- errs = append(errs, err)68+ continue
64- continue
65- }
66- out, err := drm.Open(fd, card, w, log)
67- if err != nil {
68- if !w.Strict {
69- log.Warn().Err(err).Str("card", card).Msg("card unusable")
70- errs = append(errs, err)
71- }
72- s.CloseDevice(fd)
73- continue
74- }
75- return &drmBackend{seat: s, out: out, fd: fd}, nil
76 }69 }
70+ b.cards = append(b.cards, &drmCard{Card: card, fd: fd})
77 }71 }
78- s.Close()72+ if len(b.cards) == 0 {
79- err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...))73+ s.Close()
80- log.Error().Err(err).Msg("open drm")74+ err = fmt.Errorf("no usable DRM card in %v: %w", paths, errors.Join(errs...))
81- return nil, err75+ log.Error().Err(err).Msg("open drm")
76+ return nil, err
77+ }
78+ return b, nil
82 }79 }
83 80
84-// wantFromConfig turns [[output]] entries into a connector choice: the first enabled entry wins.81+// wantFromConfig turns output.<name> entries into connector choices.
85 func wantFromConfig(outputs []ports.OutputConfig) drm.Want {82 func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
86- want := drm.Want{Disabled: map[string]bool{}}83+ want := drm.Want{Disabled: map[string]bool{}, Modes: map[string][3]float64{}}
87 for _, o := range outputs {84 for _, o := range outputs {
88 if o.Off {85 if o.Off {
89 want.Disabled[o.Name] = true86 want.Disabled[o.Name] = true
90 continue87 continue
91 }88 }
92- if want.Name != "" || o.ScaleOnly {
93- continue
94- }
95- want.Name = o.Name
96 if o.Mode != "" {89 if o.Mode != "" {
97 // Validated at config load.90 // Validated at config load.
98- want.W, want.H, want.Hz, _ = config.ParseMode(o.Mode)91+ w, h, hz, _ := config.ParseMode(o.Mode)
92+ want.Modes[o.Name] = [3]float64{float64(w), float64(h), hz}
99 }93 }
100 }94 }
101 return want95 return want
102 }96 }
103 97
104-// outputInfo describes the chosen output for wl_output and xdg-output.98+// close releases the cards and the seat so the TTY comes back. Outputs are
105-func (b *drmBackend) outputInfo() wayland.OutputInfo {99+// closed by their goroutines first.
106- i := b.out.Info()100+func (b *drmBackend) close() {
107- return wayland.OutputInfo{101+ for _, c := range b.cards {
108- Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model,102+ b.seat.CloseDevice(c.fd)
109- Description: strings.TrimSpace(i.Monitor.Make + " " + i.Monitor.Model + " " + i.Monitor.Serial + " (" + i.Name + ")"),
110- RefreshMilli: i.RefreshMilli, PhysicalW: i.PhysicalW, PhysicalH: i.PhysicalH,
111 }103 }
104+ b.seat.Close()
112 }105 }
113 106
114-// close restores the CRTC, then releases the card and the seat so the TTY comes back.107+// runOutputs drives every output of every card: one goroutine per output,
115-func (b *drmBackend) close() {108+// one flip reader per card, and a udev watcher that rescans connectors on
116- b.out.Close()109+// hotplug. Core learns about outputs through events. It returns when ctx
117- b.seat.CloseDevice(b.fd)110+// ends, after every output is closed.
118- b.seat.Close()111+func (b *drmBackend) runOutputs(ctx context.Context, want func() drm.Want, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
112+ ctx, cancel := context.WithCancel(ctx)
113+ defer cancel()
114+ var readers sync.WaitGroup
115+ readerErr := make(chan error, len(b.cards))
116+ for _, c := range b.cards {
117+ readers.Go(func() {
118+ if err := safe("drm events", func() error { return c.ReadEvents(ctx) }); err != nil {
119+ readerErr <- fmt.Errorf("%s: %w", c.Path(), err)
120+ }
121+ })
122+ }
123+ defer readers.Wait()
124+ hotplug := make(chan struct{}, 1)
125+ go func() {
126+ if err := safe("hotplug", func() error { return drm.WatchHotplug(ctx, hotplug) }); err != nil {
127+ log.Warn().Err(err).Msg("hotplug watch disabled")
128+ }
129+ }()
130+ set := newOutputSet()
131+ cards := map[string]*drmCard{}
132+ send := func(ev ports.OutputEvent) {
133+ select {
134+ case events <- ev:
135+ case <-ctx.Done():
136+ }
137+ }
138+ // stopping holds outputs asked to stop: true when they restart with a
139+ // new mode (core keeps the screen), false when they are gone.
140+ stopping := map[string]bool{}
141+ scan := func() {
142+ for _, c := range b.cards {
143+ c.SetWant(want())
144+ added, removed, replaced, err := c.Scan()
145+ if err != nil {
146+ log.Warn().Err(err).Msg("scan connectors")
147+ continue
148+ }
149+ stop := func(name string, restart bool) {
150+ if r := set.outs[name]; r != nil && cards[name] == c {
151+ if _, ok := stopping[name]; !ok {
152+ stopping[name] = restart
153+ r.stop()
154+ }
155+ }
156+ }
157+ for _, name := range removed {
158+ log.Info().Str("connector", name).Msg("output unplugged")
159+ stop(name, false)
160+ }
161+ for _, name := range replaced {
162+ log.Info().Str("connector", name).Msg("output mode change")
163+ stop(name, true)
164+ }
165+ for _, o := range added {
166+ name := o.Info().Name
167+ if other := cards[name]; other != nil && other != c {
168+ // Core and clients know outputs by connector name.
169+ log.Warn().Str("connector", name).Str("card", c.Path()).Msg("connector name already used by another card; ignored")
170+ o.Close()
171+ c.Release(name)
172+ continue
173+ }
174+ cards[name] = c
175+ if cur := o.Cursor(); cur != nil {
176+ curs.set(name, cur)
177+ }
178+ active := b.seat.Subscribe()
179+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {
180+ defer b.seat.Unsubscribe(active)
181+ return safe("output "+name, func() error {
182+ defer o.Close()
183+ return o.Run(octx, newRenderer, loadCursor, active, sc, cc)
184+ })
185+ })
186+ send(ports.OutputAdded{Info: o.Info()})
187+ }
188+ }
189+ }
190+ scan()
191+ if len(set.outs) == 0 {
192+ return errors.Join(errors.New("no connected display"), set.wait())
193+ }
194+ for {
195+ select {
196+ case <-ctx.Done():
197+ return set.wait()
198+ case err := <-readerErr:
199+ // No more flips on that card: its outputs would freeze.
200+ return errors.Join(err, set.wait())
201+ case <-hotplug:
202+ log.Info().Msg("hotplug")
203+ scan()
204+ case s := <-scenes:
205+ set.scenes(s)
206+ case c := <-contents:
207+ set.content(c)
208+ case name := <-set.stopped:
209+ err := set.finish(name)
210+ if ctx.Err() != nil {
211+ return errors.Join(err, set.wait())
212+ }
213+ restart := stopping[name]
214+ delete(stopping, name)
215+ curs.set(name, nil)
216+ cards[name].Release(name)
217+ delete(cards, name)
218+ if err != nil {
219+ // A broken output (e.g. its renderer) must not take the
220+ // session down. It stays off until the next hotplug, so a
221+ // lasting failure does not loop.
222+ log.Error().Err(err).Str("connector", name).Msg("output stopped")
223+ restart = false
224+ }
225+ if restart {
226+ // Same connector, new mode: core keeps its screen and
227+ // workspaces; OutputAdded updates the size.
228+ scan()
229+ if set.outs[name] != nil {
230+ continue
231+ }
232+ }
233+ send(ports.OutputRemoved{Name: name})
234+ }
235+ }
119 }236 }
D internal/app/drm_scale_test.go +0 -14
@@ -1,14 +0,0 @@
1-package app
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/nefertty/internal/ports"
7-)
8-
9-func TestScaleOnlyDoesNotSelectOutput(t *testing.T) {
10- want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080"}})
11- if want.Name != "HDMI-A-1" || want.W != 1920 {
12- t.Fatalf("%+v", want)
13- }
14-}
A internal/app/drm_test.go +14 -0
@@ -0,0 +1,14 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestWantFromConfig(t *testing.T) {
10+ want := wantFromConfig([]ports.OutputConfig{{Name: "eDP-1", Scale: 2, ScaleOnly: true}, {Name: "HDMI-A-1", Mode: "1920x1080@60"}, {Name: "DP-3", Off: true}})
11+ if m := want.Modes["HDMI-A-1"]; m != [3]float64{1920, 1080, 60} || !want.Disabled["DP-3"] || len(want.Modes) != 1 {
12+ t.Fatalf("%+v", want)
13+ }
14+}
A internal/app/headless.go +62 -0
@@ -0,0 +1,62 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/nefertty/internal/adapters/headless"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
15+// With several outputs, screenshots go to a subdirectory per output.
16+func runHeadless(ctx context.Context, sizes [][2]int, shots string, events chan<- ports.OutputEvent, scenes <-chan []ports.Scene, contents <-chan ports.SurfaceContent, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
17+ set := newOutputSet()
18+ for i, size := range sizes {
19+ name := fmt.Sprintf("HEADLESS-%d", i+1)
20+ dir := shots
21+ if dir != "" && len(sizes) > 1 {
22+ dir = filepath.Join(shots, name)
23+ if err := os.MkdirAll(dir, 0o755); err != nil {
24+ return err
25+ }
26+ }
27+ cur := &headless.Cursor{}
28+ curs.set(name, cur)
29+ opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, Log: log, NewRenderer: newRenderer}
30+ set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent) error {
31+ return headless.Run(octx, opts, sc, cc)
32+ })
33+ select {
34+ case events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
35+ case <-ctx.Done():
36+ return set.wait()
37+ }
38+ }
39+ // The pointer starts centred on the first output, like libinput's.
40+ curs.move("HEADLESS-1", float64(sizes[0][0])/2, float64(sizes[0][1])/2)
41+ for {
42+ select {
43+ case <-ctx.Done():
44+ return set.wait()
45+ case s := <-scenes:
46+ set.scenes(s)
47+ case c := <-contents:
48+ set.content(c)
49+ case name := <-set.stopped:
50+ // A headless output only stops on error: the run ends.
51+ err := set.finish(name)
52+ return joinErr(err, set.wait())
53+ }
54+ }
55+}
56+
57+func joinErr(a, b error) error {
58+ if a != nil {
59+ return a
60+ }
61+ return b
62+}
A internal/app/outputs.go +163 -0
@@ -0,0 +1,163 @@
1+package app
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+// outputRun drives one output until ctx ends: it renders the scenes and
11+// surface contents it receives.
12+type outputRun func(ctx context.Context, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error
13+
14+// outputSet owns the running outputs and feeds them. Core sends one scene
15+// per output; each goes to its output, latest first. Surface contents go to
16+// every output, since windows move between outputs; the latest content of
17+// each window is kept so a new output starts with every window drawn.
18+// Only the goroutine running loop touches the set.
19+type outputSet struct {
20+ outs map[string]*runningOutput
21+ latest map[ports.WindowID]ports.SurfaceContent
22+ stopped chan string
23+ quit chan struct{} // closed by wait: nobody reads stopped any more
24+}
25+
26+type runningOutput struct {
27+ scenes chan ports.Scene
28+ contents chan ports.SurfaceContent
29+ ctx context.Context
30+ stop context.CancelFunc
31+ err error
32+ done chan struct{}
33+}
34+
35+func newOutputSet() *outputSet {
36+ return &outputSet{outs: map[string]*runningOutput{}, latest: map[ports.WindowID]ports.SurfaceContent{}, stopped: make(chan string), quit: make(chan struct{})}
37+}
38+
39+// start runs an output in its own goroutine and replays the window contents.
40+func (s *outputSet) start(ctx context.Context, name string, run outputRun) {
41+ octx, stop := context.WithCancel(ctx)
42+ r := &runningOutput{scenes: make(chan ports.Scene, 1), contents: make(chan ports.SurfaceContent, 64), ctx: octx, stop: stop, done: make(chan struct{})}
43+ s.outs[name] = r
44+ go func() {
45+ r.err = run(octx, r.scenes, r.contents)
46+ close(r.done)
47+ select {
48+ case s.stopped <- name:
49+ case <-s.quit:
50+ }
51+ }()
52+ for _, c := range s.latest {
53+ r.send(c)
54+ }
55+}
56+
57+// send hands content to the output unless it has stopped.
58+func (r *runningOutput) send(c ports.SurfaceContent) {
59+ select {
60+ case r.contents <- c:
61+ case <-r.ctx.Done():
62+ case <-r.done:
63+ }
64+}
65+
66+// scenes routes each scene to its output, replacing an unread one.
67+func (s *outputSet) scenes(set []ports.Scene) {
68+ for _, sc := range set {
69+ if r := s.outs[sc.Output]; r != nil {
70+ select {
71+ case <-r.scenes:
72+ default:
73+ }
74+ r.scenes <- sc
75+ }
76+ }
77+}
78+
79+func (s *outputSet) content(c ports.SurfaceContent) {
80+ if c.Pixels == nil {
81+ delete(s.latest, c.ID)
82+ } else {
83+ s.latest[c.ID] = c
84+ }
85+ for _, r := range s.outs {
86+ r.send(c)
87+ }
88+}
89+
90+// finish forgets a stopped output and returns its error.
91+func (s *outputSet) finish(name string) error {
92+ r := s.outs[name]
93+ if r == nil {
94+ return nil
95+ }
96+ <-r.done
97+ r.stop()
98+ delete(s.outs, name)
99+ return r.err
100+}
101+
102+// wait stops every output and returns the first error.
103+func (s *outputSet) wait() error {
104+ var first error
105+ close(s.quit)
106+ for name, r := range s.outs {
107+ r.stop()
108+ <-r.done
109+ if first == nil && r.err != nil {
110+ first = r.err
111+ }
112+ delete(s.outs, name)
113+ }
114+ return first
115+}
116+
117+// cursors routes pointer moves from the input goroutine to the cursor of
118+// the output under the pointer and hides the others.
119+type cursors struct {
120+ mu sync.Mutex
121+ all map[string]cursor
122+ last string
123+}
124+
125+// cursor is a hardware or software cursor of one output.
126+type cursor interface {
127+ Move(x, y float64)
128+ Hide()
129+}
130+
131+func newCursors() *cursors { return &cursors{all: map[string]cursor{}} }
132+
133+// set registers the cursor of an output; nil removes it.
134+func (c *cursors) set(output string, cur cursor) {
135+ c.mu.Lock()
136+ defer c.mu.Unlock()
137+ if cur == nil {
138+ delete(c.all, output)
139+ return
140+ }
141+ c.all[output] = cur
142+ // Before the first move nothing is shown yet: hiding would stick.
143+ if c.last != "" && output != c.last {
144+ cur.Hide()
145+ }
146+}
147+
148+// move places the cursor on output at physical (x, y).
149+func (c *cursors) move(output string, x, y float64) {
150+ c.mu.Lock()
151+ defer c.mu.Unlock()
152+ if output != c.last {
153+ for name, cur := range c.all {
154+ if name != output {
155+ cur.Hide()
156+ }
157+ }
158+ c.last = output
159+ }
160+ if cur := c.all[output]; cur != nil {
161+ cur.Move(x, y)
162+ }
163+}
M internal/core/action.go +41 -0
@@ -24,6 +24,11 @@ const (
24 ActionFocusWorkspaceDown Action = "focus-workspace-down"24 ActionFocusWorkspaceDown Action = "focus-workspace-down"
25 ActionMoveToWorkspaceUp Action = "move-to-workspace-up"25 ActionMoveToWorkspaceUp Action = "move-to-workspace-up"
26 ActionMoveToWorkspaceDown Action = "move-to-workspace-down"26 ActionMoveToWorkspaceDown Action = "move-to-workspace-down"
27+ // Monitors are ordered left to right (ADR 011).
28+ ActionFocusMonitorLeft Action = "focus-monitor-left"
29+ ActionFocusMonitorRight Action = "focus-monitor-right"
30+ ActionMoveWorkspaceLeft Action = "move-workspace-to-monitor-left"
31+ ActionMoveWorkspaceRight Action = "move-workspace-to-monitor-right"
27 // Scale steps through the clean scales of the output (see CleanScales).32 // Scale steps through the clean scales of the output (see CleanScales).
28 ActionScaleUp Action = "scale-up"33 ActionScaleUp Action = "scale-up"
29 ActionScaleDown Action = "scale-down"34 ActionScaleDown Action = "scale-down"
@@ -77,6 +82,42 @@ type Effect struct {
77 Quit bool82 Quit bool
78 }83 }
79 84
85+// applyAction runs a bind action. Monitor actions change the focused
86+// screen; column focus past the edge column moves to the neighbor screen;
87+// everything else applies to the focused screen's monitor.
88+func (c *Core) applyAction(a Action) Effect {
89+ dir := 0
90+ switch a {
91+ case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
92+ dir = -1
93+ case ActionFocusMonitorRight, ActionMoveWorkspaceRight:
94+ dir = 1
95+ }
96+ switch a {
97+ case ActionFocusMonitorLeft, ActionFocusMonitorRight:
98+ if i := c.neighbor(dir); i >= 0 {
99+ c.focusScreen = i
100+ }
101+ return Effect{}
102+ case ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
103+ c.moveWorkspace(dir)
104+ return Effect{}
105+ case ActionFocusColumnLeft, ActionFocusColumnRight:
106+ w := c.cur().mon.Current()
107+ edge := len(w.Columns) == 0 || (a == ActionFocusColumnLeft && w.Focus == 0) || (a == ActionFocusColumnRight && w.Focus == len(w.Columns)-1)
108+ if a == ActionFocusColumnLeft {
109+ dir = -1
110+ } else {
111+ dir = 1
112+ }
113+ if i := c.neighbor(dir); edge && i >= 0 {
114+ c.focusScreen = i
115+ return Effect{}
116+ }
117+ }
118+ return c.cur().mon.Apply(a)
119+}
120+
80 // Apply runs a bind action on the monitor.121 // Apply runs a bind action on the monitor.
81 func (m *Monitor) Apply(a Action) Effect {122 func (m *Monitor) Apply(a Action) Effect {
82 if n, move, ok := WorkspaceArg(a); ok {123 if n, move, ok := WorkspaceArg(a); ok {
M internal/core/core.go +184 -135
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "github.com/bnema/nefertty/internal/ports"7 "github.com/bnema/nefertty/internal/ports"
8+ "slices"
8 "strconv"9 "strconv"
9 "strings"10 "strings"
10 "unicode"11 "unicode"
@@ -12,16 +13,20 @@ import (
12 13
13 var ErrQuit = errors.New("quit requested")14 var ErrQuit = errors.New("quit requested")
14 15
15-// Channels connects the owner to adapters. scenes must have capacity 1: core is16+// Channels connects the owner to adapters. Scenes must have capacity 1: core is
16-// its only sender and may drain a stale scene; consumers only receive.17+// its only sender and may drain a stale set; consumers only receive. Each
18+// send holds one scene per output.
17 type Channels struct {19 type Channels struct {
18- Client <-chan ports.ClientEvent20+ Client <-chan ports.ClientEvent
19- Input <-chan ports.InputEvent21+ Input <-chan ports.InputEvent
20- Output <-chan ports.OutputEvent22+ Output <-chan ports.OutputEvent
21- Config <-chan ports.ConfigChanged23+ Config <-chan ports.ConfigChanged
22- Commands chan<- ports.ClientCommand24+ Commands chan<- ports.ClientCommand
23- Spawn chan<- ports.SpawnRequest25+ Spawn chan<- ports.SpawnRequest
24- Scenes chan ports.Scene26+ Scenes chan []ports.Scene
27+ // Layouts, when set, receives the output layout whenever it changes,
28+ // latest first (capacity 1, drained like Scenes). Input and cursors use it.
29+ Layouts chan ports.Layout
25 ConfigErrors chan<- error30 ConfigErrors chan<- error
26 }31 }
27 type binding struct {32 type binding struct {
@@ -31,7 +36,8 @@ type binding struct {
31 type Core struct {36 type Core struct {
32 ch Channels37 ch Channels
33 cfg ports.Config38 cfg ports.Config
34- ws *Monitor39+ screens []*screen
40+ focusScreen int
35 binds map[binding]Action41 binds map[binding]Action
36 pressed map[string]bool42 pressed map[string]bool
37 sent map[WindowID]ports.ConfigureWindow43 sent map[WindowID]ports.ConfigureWindow
@@ -39,18 +45,15 @@ type Core struct {
39 pointer WindowID45 pointer WindowID
40 grab WindowID46 grab WindowID
41 buttons map[uint32]bool47 buttons map[uint32]bool
42- cursorX, cursorY float6448+ cursorX, cursorY float64 // global, logical
43 seq uint6449 seq uint64
44- layers []ports.LayerSurface50+ // layerChanged is set once layer state arrives from wayland.
45- placed []ports.SceneLayer51+ layerChanged bool
46- layerChanged bool52+ sentOutputs ports.SetOutputs
47- // Output in physical pixels. scale comes from output.<name>.scale and53+ specs []NamedWorkspace
48- // changes live with scale-up/scale-down until the config value changes.54+ presets []Width
49- outName string55+ overflow Overflow
50- physW, physH int56+ slots map[slotKey]*slotState
51- scale, cfgScale float64
52- sentScale ports.SetOutputScale
53- slots map[slotKey]*slotState
54 // toSpawn holds slots to start; Run sends them (apply has no context).57 // toSpawn holds slots to start; Run sends them (apply has no context).
55 toSpawn []slotKey58 toSpawn []slotKey
56 sentPending bool59 sentPending bool
@@ -140,7 +143,7 @@ func (c *Core) apply(cfg ports.Config) error {
140 continue143 continue
141 }144 }
142 switch Action(a) {145 switch Action(a) {
143- case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown:146+ case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown, ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight:
144 default:147 default:
145 return fmt.Errorf("invalid action %q", a)148 return fmt.Errorf("invalid action %q", a)
146 }149 }
@@ -171,7 +174,7 @@ func (c *Core) apply(cfg ports.Config) error {
171 return fmt.Errorf("invalid workspace %q", ws.Name)174 return fmt.Errorf("invalid workspace %q", ws.Name)
172 }175 }
173 seen[ws.Name] = true176 seen[ws.Name] = true
174- named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})177+ named = append(named, NamedWorkspace{Name: ws.Name, Monitor: ws.Monitor, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o})
175 }178 }
176 specs, err := parseSlots(cfg.Workspaces)179 specs, err := parseSlots(cfg.Workspaces)
177 if err != nil {180 if err != nil {
@@ -179,13 +182,13 @@ func (c *Core) apply(cfg ports.Config) error {
179 }182 }
180 c.cfg = cfg183 c.cfg = cfg
181 c.binds = binds184 c.binds = binds
182- c.applyConfigScale()185+ c.specs, c.presets, c.overflow = named, presets, defOverflow
183 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)186 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
184- c.ws.SetNamed(named)187+ c.named()
185- c.ws.SetOverflow(defOverflow)188+ for _, s := range c.screens {
186- c.ws.SetMaxColumns(cfg.Layout.MaxColumns)189+ c.settings(s.mon)
187- c.ws.SetPresets(presets)190+ }
188- c.ws.SetGaps(cfg.Layout.Gaps)191+ c.applyConfigScales()
189 return nil192 return nil
190 }193 }
191 194
@@ -232,10 +235,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
232 return nil235 return nil
233 }236 }
234 func New(cfg ports.Config, ch Channels) (*Core, error) {237 func New(cfg ports.Config, ch Channels) (*Core, error) {
235- if cap(ch.Scenes) != 1 {238+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) {
236- return nil, fmt.Errorf("scenes must have capacity 1")239+ return nil, fmt.Errorf("scenes and layouts must have capacity 1")
237 }240 }
238- c := &Core{scale: 1, ws: NewMonitor(), slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}241+ // A placeholder screen holds windows until the first output arrives.
242+ c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
239 if err := c.apply(cfg); err != nil {243 if err := c.apply(cfg); err != nil {
240 return nil, err244 return nil, err
241 }245 }
@@ -250,33 +254,6 @@ func (c *Core) command(ctx context.Context, v ports.ClientCommand) error {
250 }254 }
251 }255 }
252 256
253-// applyConfigScale picks up output.<name>.scale when it changed in the config,
254-// replacing any live scale-up/scale-down adjustment.
255-func (c *Core) applyConfigScale() {
256- s := 1.0
257- for _, o := range c.cfg.Outputs {
258- if o.Name == c.outName && o.Scale != 0 {
259- s = o.Scale
260- }
261- }
262- s = SnapScale(s)
263- if s != c.cfgScale {
264- c.cfgScale = s
265- c.setScale(s)
266- }
267-}
268-
269-// setScale resizes the logical output; layer placement follows.
270-func (c *Core) setScale(s float64) {
271- c.scale = SnapScale(s)
272- c.ws.SetOutput(logical(c.physW, c.scale), logical(c.physH, c.scale))
273- if c.layerChanged {
274- var usable ports.Rect
275- c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
276- c.ws.SetUsable(usable)
277- }
278-}
279-
280 // clientRect is the placement minus the border drawn around the client.257 // clientRect is the placement minus the border drawn around the client.
281 // The renderer applies the same inset (ports.Border).258 // The renderer applies the same inset (ports.Border).
282 func (c *Core) clientRect(p Placement) Rect {259 func (c *Core) clientRect(p Placement) Rect {
@@ -288,69 +265,104 @@ func (c *Core) clientRect(p Placement) Rect {
288 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}265 return Rect{X: r.X + b, Y: r.Y + b, W: r.W - 2*b, H: r.H - 2*b}
289 }266 }
290 267
291-// visible reports whether the window is on screen on the active workspace.268+// visible reports whether the window is on screen on any output.
292 func (c *Core) visible(id WindowID) bool {269 func (c *Core) visible(id WindowID) bool {
293- for _, p := range c.ws.Layout() {270+ for _, sc := range c.screens {
294- if p.ID == id {271+ for _, p := range sc.mon.Layout() {
295- return !p.Hidden272+ if p.ID == id {
273+ return !p.Hidden
274+ }
296 }275 }
297 }276 }
298 return false277 return false
299 }278 }
300 279
301 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard280 // keyboardFocus is the mapped top/overlay layer with exclusive keyboard
302-// interactivity and the highest ID, else the focused window. On-demand layers281+// interactivity and the highest ID on any output, else the focused window of
303-// never take focus automatically. When the layer unmaps, focus returns to the window.282+// the focused output. On-demand layers never take focus automatically. When
283+// the layer unmaps, focus returns to the window.
304 func (c *Core) keyboardFocus() WindowID {284 func (c *Core) keyboardFocus() WindowID {
305 var layer WindowID285 var layer WindowID
306- for _, l := range c.layers {286+ for _, sc := range c.screens {
307- if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > layer {287+ for _, l := range sc.layers {
308- layer = l.ID288+ if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > layer {
289+ layer = l.ID
290+ }
309 }291 }
310 }292 }
311 if layer != 0 {293 if layer != 0 {
312 return layer294 return layer
313 }295 }
314- id, _ := c.ws.Focused()296+ id, _ := c.cur().mon.Focused()
315 return id297 return id
316 }298 }
317 299
300+// setLayers splits the mapped layer surfaces by output. A surface without an
301+// output, or on an unknown one, goes to the focused output.
302+func (c *Core) setLayers(all []ports.LayerSurface) {
303+ for _, sc := range c.screens {
304+ sc.layers = nil
305+ }
306+ for _, l := range all {
307+ sc := c.cur()
308+ if i := c.screenIndex(l.Output); i >= 0 {
309+ sc = c.screens[i]
310+ }
311+ sc.layers = append(sc.layers, l)
312+ }
313+ for _, sc := range c.screens {
314+ sc.arrange()
315+ }
316+}
317+
318 func (c *Core) publish(ctx context.Context) error {318 func (c *Core) publish(ctx context.Context) error {
319- // Clients learn the new scale before the configures sized for it.319+ // Clients learn outputs and scales before the configures sized for them.
320- if v := (ports.SetOutputScale{Scale: c.scale, Width: c.ws.Output().W, Height: c.ws.Output().H}); v != c.sentScale {320+ if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) {
321 if err := c.command(ctx, v); err != nil {321 if err := c.command(ctx, v); err != nil {
322 return err322 return err
323 }323 }
324- c.sentScale = v324+ if !slices.Equal(v.Outputs, c.sentOutputs.Outputs) && c.ch.Layouts != nil {
325+ latest(c.ch.Layouts, v.Outputs)
326+ }
327+ c.sentOutputs = v
325 }328 }
326 if err := c.publishPending(ctx); err != nil {329 if err := c.publishPending(ctx); err != nil {
327 return err330 return err
328 }331 }
329- c.seq++
330- scene := ports.Scene{Seq: c.seq, OutputWidth: c.ws.Output().W, OutputHeight: c.ws.Output().H, Scale: c.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), c.placed...)}
331 alive := map[WindowID]bool{}332 alive := map[WindowID]bool{}
332 focus := c.keyboardFocus()333 focus := c.keyboardFocus()
333- for _, p := range c.ws.Layout() {334+ scenes := make([]ports.Scene, 0, len(c.screens))
334- alive[p.ID] = true335+ // Before the first output (and after the last is unplugged) the
335- scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: p.Focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})336+ // placeholder's scene has no output name: no renderer draws it.
336- if p.Hidden {337+ for i, sc := range c.screens {
337- if old, ok := c.sent[p.ID]; ok && old.Activated {338+ c.seq++
338- old.Activated = false339+ o := sc.mon.Output()
339- if err := c.command(ctx, old); err != nil {340+ scene := ports.Scene{Output: sc.name(), Seq: c.seq, OutputWidth: o.W, OutputHeight: o.H, Scale: sc.scale, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0), Layers: append([]ports.SceneLayer(nil), sc.placed...)}
340- return err341+ for _, p := range sc.mon.Layout() {
342+ alive[p.ID] = true
343+ // Only the focused output shows the focused border.
344+ focused := p.Focused && i == c.focusScreen
345+ scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless})
346+ if p.Hidden {
347+ if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
348+ old.Activated, old.Output = false, ""
349+ if err := c.command(ctx, old); err != nil {
350+ return err
351+ }
352+ c.sent[p.ID] = old
341 }353 }
342- c.sent[p.ID] = old354+ continue
343 }355 }
344- continue356+ r := c.clientRect(p)
345- }357+ v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Output: sc.name()}
346- r := c.clientRect(p)358+ if old, ok := c.sent[p.ID]; !ok || old != v {
347- v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: p.Focused}359+ if err := c.command(ctx, v); err != nil {
348- if old, ok := c.sent[p.ID]; !ok || old != v {360+ return err
349- if err := c.command(ctx, v); err != nil {361+ }
350- return err362+ c.sent[p.ID] = v
351 }363 }
352- c.sent[p.ID] = v
353 }364 }
365+ scenes = append(scenes, scene)
354 }366 }
355 for id := range c.sent {367 for id := range c.sent {
356 if !alive[id] {368 if !alive[id] {
@@ -373,21 +385,65 @@ func (c *Core) publish(ctx context.Context) error {
373 }385 }
374 c.focus = focus386 c.focus = focus
375 }387 }
376- // Only the owner sends and drains; a renderer only receives.388+ latest(c.ch.Scenes, scenes)
389+ return nil
390+}
391+
392+// latest replaces any unread value: only the owner sends and drains;
393+// consumers only receive.
394+func latest[T any](ch chan T, v T) {
377 select {395 select {
378- case c.ch.Scenes <- scene:396+ case ch <- v:
379 default:397 default:
380 select {398 select {
381- case <-c.ch.Scenes:399+ case <-ch:
382 default:400 default:
383 }401 }
384 select {402 select {
385- case c.ch.Scenes <- scene:403+ case ch <- v:
386 default:404 default:
387 }405 }
388 }406 }
389- return nil
390 }407 }
408+
409+func sameOutputs(a, b ports.SetOutputs) bool {
410+ return a.Focused == b.Focused && slices.Equal(a.Outputs, b.Outputs)
411+}
412+
413+// allLayers lists the layer surfaces of every output.
414+func (c *Core) allLayers() []ports.LayerSurface {
415+ var all []ports.LayerSurface
416+ for _, sc := range c.screens {
417+ all = append(all, sc.layers...)
418+ }
419+ return all
420+}
421+
422+// hit returns the window under the global logical point and the point in
423+// its surface coordinates. Fullscreen wins; otherwise the last visible
424+// placement is topmost.
425+func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
426+ o, ok := c.layout().At(x, y)
427+ if !ok {
428+ return 0, 0, 0
429+ }
430+ lx, ly := x-float64(o.X), y-float64(o.Y)
431+ var id WindowID
432+ var sx, sy float64
433+ for _, p := range c.screens[c.screenIndex(o.Info.Name)].mon.Layout() {
434+ r := c.clientRect(p)
435+ if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) {
436+ if id == 0 || p.Fullscreen {
437+ id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
438+ }
439+ if p.Fullscreen {
440+ break
441+ }
442+ }
443+ }
444+ return id, sx, sy
445+}
446+
391 func (c *Core) Run(ctx context.Context) error {447 func (c *Core) Run(ctx context.Context) error {
392 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {448 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
393 return nil449 return nil
@@ -403,17 +459,18 @@ func (c *Core) Run(ctx context.Context) error {
403 }459 }
404 switch v := ev.(type) {460 switch v := ev.(type) {
405 case ports.LayerChanged:461 case ports.LayerChanged:
406- c.layers = append([]ports.LayerSurface(nil), v.Layers...)
407 c.layerChanged = true462 c.layerChanged = true
408- var usable ports.Rect463+ c.setLayers(v.Layers)
409- c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers)
410- c.ws.SetUsable(usable)
411 case ports.WindowMapped:464 case ports.WindowMapped:
412 if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {465 if v.Slot == "" || !c.placeSlotWindow(v.ID, v.Slot) {
413- c.ws.AddWindow(v.ID)466+ if s, _ := c.screenOf(v.ID); s == nil {
467+ c.cur().mon.AddWindow(v.ID)
468+ }
414 }469 }
415 case ports.WindowUnmapped:470 case ports.WindowUnmapped:
416- c.ws.RemoveWindow(v.ID)471+ if s, _ := c.screenOf(v.ID); s != nil {
472+ s.mon.RemoveWindow(v.ID)
473+ }
417 c.releaseSlots()474 c.releaseSlots()
418 if c.pointer == v.ID {475 if c.pointer == v.ID {
419 c.pointer = 0476 c.pointer = 0
@@ -422,7 +479,9 @@ func (c *Core) Run(ctx context.Context) error {
422 }479 }
423 }480 }
424 case ports.WindowFullscreenRequest:481 case ports.WindowFullscreenRequest:
425- c.ws.SetFullscreen(v.ID, v.Fullscreen)482+ if s, _ := c.screenOf(v.ID); s != nil {
483+ s.mon.SetFullscreen(v.ID, v.Fullscreen)
484+ }
426 }485 }
427 if err := c.publish(ctx); err != nil {486 if err := c.publish(ctx); err != nil {
428 return nil487 return nil
@@ -433,18 +492,17 @@ func (c *Core) Run(ctx context.Context) error {
433 continue492 continue
434 }493 }
435 switch v := ev.(type) {494 switch v := ev.(type) {
436- case ports.OutputMode:495+ case ports.OutputAdded:
437- c.physW, c.physH = v.Width, v.Height496+ c.addScreen(v.Info)
438- if v.Name != c.outName {497+ if c.layerChanged {
439- c.outName, c.cfgScale = v.Name, 0498+ c.setLayers(c.allLayers())
440 }499 }
441- c.applyConfigScale()500+ case ports.OutputRemoved:
442- c.setScale(c.scale)501+ c.removeScreen(v.Name)
443- case ports.OutputUsable:502+ if c.layerChanged {
444- // Once layer state arrives, its exclusive zones take precedence.503+ c.setLayers(c.allLayers())
445- if !c.layerChanged {
446- c.ws.SetUsable(v.Rect)
447 }504 }
505+ c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY)
448 }506 }
449 if err := c.publish(ctx); err != nil {507 if err := c.publish(ctx); err != nil {
450 return nil508 return nil
@@ -474,23 +532,8 @@ func (c *Core) Run(ctx context.Context) error {
474 }532 }
475 switch v := ev.(type) {533 switch v := ev.(type) {
476 case ports.PointerMotion:534 case ports.PointerMotion:
477- // Input reports physical pixels; hit-testing is logical.535+ c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)
478- c.cursorX = min(max(v.X/c.scale, 0), float64(max(c.ws.Output().W-1, 0)))536+ id, x, y := c.hit(c.cursorX, c.cursorY)
479- c.cursorY = min(max(v.Y/c.scale, 0), float64(max(c.ws.Output().H-1, 0)))
480- var id WindowID
481- var x, y float64
482- // Fullscreen wins; otherwise the last visible placement is topmost.
483- for _, p := range c.ws.Layout() {
484- r := c.clientRect(p)
485- if !p.Hidden && r.W > 0 && r.H > 0 && c.cursorX >= float64(r.X) && c.cursorX < float64(r.X+r.W) && c.cursorY >= float64(r.Y) && c.cursorY < float64(r.Y+r.H) {
486- if id == 0 || p.Fullscreen {
487- id, x, y = p.ID, c.cursorX-float64(r.X), c.cursorY-float64(r.Y)
488- }
489- if p.Fullscreen {
490- break
491- }
492- }
493- }
494 // Layout changes are intentionally re-hit-tested only on motion.537 // Layout changes are intentionally re-hit-tested only on motion.
495 if id != c.pointer {538 if id != c.pointer {
496 c.pointer = id539 c.pointer = id
@@ -521,7 +564,10 @@ func (c *Core) Run(ctx context.Context) error {
521 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {564 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
522 return nil565 return nil
523 }566 }
524- if v.Pressed && c.focus != id && c.ws.Current().FocusID(id) {567+ // A click focuses the window and its output.
568+ if s, w := c.screenOf(id); v.Pressed && s != nil && w == s.mon.Current() && (c.focus != id || s != c.cur()) {
569+ w.FocusID(id)
570+ c.focusScreen = c.screenIndex(s.name())
525 if err := c.publish(ctx); err != nil {571 if err := c.publish(ctx); err != nil {
526 return nil572 return nil
527 }573 }
@@ -566,14 +612,16 @@ func (c *Core) Run(ctx context.Context) error {
566 if action == ActionScaleDown {612 if action == ActionScaleDown {
567 dir = -1613 dir = -1
568 }614 }
569- c.setScale(StepScale(c.physW, c.physH, c.scale, dir))615+ sc := c.cur()
616+ sc.setScale(StepScale(sc.info.Width, sc.info.Height, sc.scale, dir))
617+ c.order()
570 if err := c.publish(ctx); err != nil {618 if err := c.publish(ctx); err != nil {
571 return nil619 return nil
572 }620 }
573 continue621 continue
574 }622 }
575- before := c.ws.Current()623+ before := c.cur().mon.Current()
576- effect := c.ws.Apply(action)624+ effect := c.applyAction(action)
577 if effect.Quit {625 if effect.Quit {
578 return ErrQuit626 return ErrQuit
579 }627 }
@@ -590,7 +638,8 @@ func (c *Core) Run(ctx context.Context) error {
590 }638 }
591 // Showing a declared workspace refills its empty slots.639 // Showing a declared workspace refills its empty slots.
592 c.releaseSlots()640 c.releaseSlots()
593- if c.spawnSlots(ctx, c.ws.Current() != before) != nil {641+ c.settleGuests()
642+ if c.spawnSlots(ctx, c.cur().mon.Current() != before) != nil {
594 return nil643 return nil
595 }644 }
596 if effect.Close != 0 {645 if effect.Close != 0 {
M internal/core/core_test.go +75 -69
@@ -22,12 +22,18 @@ func receive[T any](t *testing.T, ch <-chan T) T {
22 }22 }
23 }23 }
24 24
25+// scene receives the next scene set and returns the first output's scene.
26+func scene(t *testing.T, ch <-chan []ports.Scene) ports.Scene {
27+ t.Helper()
28+ return receive(t, ch)[0]
29+}
30+
25 // command receives the next client command, skipping output scale updates.31 // command receives the next client command, skipping output scale updates.
26 func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {32 func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
27 t.Helper()33 t.Helper()
28 for {34 for {
29 if v := receive(t, ch); v != nil {35 if v := receive(t, ch); v != nil {
30- if _, ok := v.(ports.SetOutputScale); !ok {36+ if _, ok := v.(ports.SetOutputs); !ok {
31 return v37 return v
32 }38 }
33 }39 }
@@ -45,7 +51,7 @@ func TestOwner(t *testing.T) {
45 reload := make(chan ports.ConfigChanged, 8)51 reload := make(chan ports.ConfigChanged, 8)
46 commands := make(chan ports.ClientCommand, 256)52 commands := make(chan ports.ClientCommand, 256)
47 spawn := make(chan ports.SpawnRequest, 8)53 spawn := make(chan ports.SpawnRequest, 8)
48- scenes := make(chan ports.Scene, 1)54+ scenes := make(chan []ports.Scene, 1)
49 errs := make(chan error, 8)55 errs := make(chan error, 8)
50 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs})56 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: errs})
51 if err != nil {57 if err != nil {
@@ -55,23 +61,23 @@ func TestOwner(t *testing.T) {
55 defer cancel()61 defer cancel()
56 done := make(chan error, 1)62 done := make(chan error, 1)
57 go func() { done <- c.Run(ctx) }()63 go func() { done <- c.Run(ctx) }()
58- output <- ports.OutputMode{Width: 100, Height: 80}64+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
59- s := receive(t, scenes)65+ s := scene(t, scenes)
60 if s.OutputWidth != 100 {66 if s.OutputWidth != 100 {
61 t.Fatal(s)67 t.Fatal(s)
62 }68 }
63 client <- ports.WindowMapped{ID: 1}69 client <- ports.WindowMapped{ID: 1}
64- s = receive(t, scenes)70+ s = scene(t, scenes)
65 // A single column fills the usable width.71 // A single column fills the usable width.
66 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {72 if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless {
67 t.Fatal(s)73 t.Fatal(s)
68 }74 }
69- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true}) {75+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 84, Height: 64, Activated: true, Output: "OUT-1"}) {
70 t.Fatal(v)76 t.Fatal(v)
71 }77 }
72 command(t, commands)78 command(t, commands)
73 client <- ports.WindowMapped{ID: 2}79 client <- ports.WindowMapped{ID: 2}
74- s = receive(t, scenes)80+ s = scene(t, scenes)
75 if len(s.Windows) != 2 {81 if len(s.Windows) != 2 {
76 t.Fatal(s)82 t.Fatal(s)
77 }83 }
@@ -80,12 +86,12 @@ func TestOwner(t *testing.T) {
80 <-commands86 <-commands
81 }87 }
82 client <- ports.WindowMapped{ID: 2}88 client <- ports.WindowMapped{ID: 2}
83- receive(t, scenes)89+ scene(t, scenes)
84 if len(commands) != 0 {90 if len(commands) != 0 {
85 t.Fatal("duplicate configure")91 t.Fatal("duplicate configure")
86 }92 }
87 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true}93 input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true}
88- receive(t, scenes)94+ scene(t, scenes)
89 if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" {95 if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" {
90 t.Fatal(v)96 t.Fatal(v)
91 }97 }
@@ -95,22 +101,22 @@ func TestOwner(t *testing.T) {
95 t.Fatal(v)101 t.Fatal(v)
96 }102 }
97 input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}103 input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true}
98- receive(t, scenes)104+ scene(t, scenes)
99 if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) {105 if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) {
100 t.Fatal(v)106 t.Fatal(v)
101 }107 }
102 client <- ports.WindowUnmapped{ID: 2}108 client <- ports.WindowUnmapped{ID: 2}
103- receive(t, scenes)109+ scene(t, scenes)
104 for len(commands) > 0 {110 for len(commands) > 0 {
105 <-commands111 <-commands
106 }112 }
107 cfg.Layout.Gaps = 4113 cfg.Layout.Gaps = 4
108 reload <- ports.ConfigChanged{Config: cfg}114 reload <- ports.ConfigChanged{Config: cfg}
109- s = receive(t, scenes)115+ s = scene(t, scenes)
110 if s.Windows[0].Rect.W != 92 {116 if s.Windows[0].Rect.W != 92 {
111 t.Fatal(s)117 t.Fatal(s)
112 }118 }
113- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true}) {119+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 92, Height: 72, Activated: true, Output: "OUT-1"}) {
114 t.Fatal(v)120 t.Fatal(v)
115 }121 }
116 cfg.Layout.MaxColumns = 0122 cfg.Layout.MaxColumns = 0
@@ -126,11 +132,11 @@ func TestOwner(t *testing.T) {
126 }132 }
127 func TestCancelAndSceneCapacity(t *testing.T) {133 func TestCancelAndSceneCapacity(t *testing.T) {
128 cfg := config.Defaults()134 cfg := config.Defaults()
129- if _, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 2)}); err == nil {135+ if _, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 2)}); err == nil {
130 t.Fatal("capacity")136 t.Fatal("capacity")
131 }137 }
132 ctx, cancel := context.WithCancel(context.Background())138 ctx, cancel := context.WithCancel(context.Background())
133- c, err := core.New(cfg, core.Channels{Scenes: make(chan ports.Scene, 1)})139+ c, err := core.New(cfg, core.Channels{Scenes: make(chan []ports.Scene, 1)})
134 if err != nil {140 if err != nil {
135 t.Fatal(err)141 t.Fatal(err)
136 }142 }
@@ -147,7 +153,7 @@ func TestKeyPressState(t *testing.T) {
147 client := make(chan ports.ClientEvent, 8)153 client := make(chan ports.ClientEvent, 8)
148 input := make(chan ports.InputEvent, 8)154 input := make(chan ports.InputEvent, 8)
149 commands := make(chan ports.ClientCommand, 16)155 commands := make(chan ports.ClientCommand, 16)
150- scenes := make(chan ports.Scene, 1)156+ scenes := make(chan []ports.Scene, 1)
151 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})157 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes})
152 if err != nil {158 if err != nil {
153 t.Fatal(err)159 t.Fatal(err)
@@ -157,12 +163,12 @@ func TestKeyPressState(t *testing.T) {
157 go func() { done <- c.Run(ctx) }()163 go func() { done <- c.Run(ctx) }()
158 defer func() { cancel(); receive(t, done) }()164 defer func() { cancel(); receive(t, done) }()
159 client <- ports.WindowMapped{ID: 1}165 client <- ports.WindowMapped{ID: 1}
160- receive(t, scenes)166+ scene(t, scenes)
161 command(t, commands)167 command(t, commands)
162 command(t, commands)168 command(t, commands)
163 press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}169 press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
164 input <- press170 input <- press
165- receive(t, scenes)171+ scene(t, scenes)
166 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}172 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper}
167 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}173 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
168 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) {174 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "Left", Pressed: true}}) {
@@ -173,7 +179,7 @@ func TestKeyPressState(t *testing.T) {
173 t.Fatal(v)179 t.Fatal(v)
174 }180 }
175 input <- press181 input <- press
176- receive(t, scenes)182+ scene(t, scenes)
177 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}183 input <- ports.KeyEvent{Keysym: "Left", Pressed: true}
178 command(t, commands)184 command(t, commands)
179 input <- ports.KeyEvent{Keysym: "Left"}185 input <- ports.KeyEvent{Keysym: "Left"}
@@ -187,7 +193,7 @@ func TestBoundReleaseSwallowed(t *testing.T) {
187 input := make(chan ports.InputEvent, 4)193 input := make(chan ports.InputEvent, 4)
188 client := make(chan ports.ClientEvent, 2)194 client := make(chan ports.ClientEvent, 2)
189 commands := make(chan ports.ClientCommand, 8)195 commands := make(chan ports.ClientCommand, 8)
190- scenes := make(chan ports.Scene, 1)196+ scenes := make(chan []ports.Scene, 1)
191 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})197 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
192 if err != nil {198 if err != nil {
193 t.Fatal(err)199 t.Fatal(err)
@@ -197,11 +203,11 @@ func TestBoundReleaseSwallowed(t *testing.T) {
197 go func() { done <- c.Run(ctx) }()203 go func() { done <- c.Run(ctx) }()
198 defer func() { cancel(); receive(t, done) }()204 defer func() { cancel(); receive(t, done) }()
199 client <- ports.WindowMapped{ID: 1}205 client <- ports.WindowMapped{ID: 1}
200- receive(t, scenes)206+ scene(t, scenes)
201 command(t, commands)207 command(t, commands)
202 command(t, commands)208 command(t, commands)
203 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}209 input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true}
204- receive(t, scenes)210+ scene(t, scenes)
205 input <- ports.KeyEvent{Keysym: "Left"}211 input <- ports.KeyEvent{Keysym: "Left"}
206 input <- ports.KeyEvent{Keysym: "x", Pressed: true}212 input <- ports.KeyEvent{Keysym: "x", Pressed: true}
207 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {213 if v := command(t, commands); v != (ports.ForwardKey{ID: 1, Key: ports.KeyEvent{Keysym: "x", Pressed: true}}) {
@@ -217,7 +223,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
217 input := make(chan ports.InputEvent, 8)223 input := make(chan ports.InputEvent, 8)
218 output := make(chan ports.OutputEvent, 8)224 output := make(chan ports.OutputEvent, 8)
219 commands := make(chan ports.ClientCommand, 64)225 commands := make(chan ports.ClientCommand, 64)
220- scenes := make(chan ports.Scene, 1)226+ scenes := make(chan []ports.Scene, 1)
221 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})227 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
222 if err != nil {228 if err != nil {
223 t.Fatal(err)229 t.Fatal(err)
@@ -225,12 +231,12 @@ func TestPointerFocusAndGrab(t *testing.T) {
225 ctx, cancel := context.WithCancel(context.Background())231 ctx, cancel := context.WithCancel(context.Background())
226 defer cancel()232 defer cancel()
227 go c.Run(ctx)233 go c.Run(ctx)
228- output <- ports.OutputMode{Width: 100, Height: 80}234+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
229- receive(t, scenes)235+ scene(t, scenes)
230 client <- ports.WindowMapped{ID: 1}236 client <- ports.WindowMapped{ID: 1}
231- first := receive(t, scenes).Windows[0].Rect237+ first := scene(t, scenes).Windows[0].Rect
232 client <- ports.WindowMapped{ID: 2}238 client <- ports.WindowMapped{ID: 2}
233- receive(t, scenes)239+ scene(t, scenes)
234 for len(commands) > 0 {240 for len(commands) > 0 {
235 <-commands241 <-commands
236 }242 }
@@ -245,7 +251,7 @@ func TestPointerFocusAndGrab(t *testing.T) {
245 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {251 if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) {
246 t.Fatal(v)252 t.Fatal(v)
247 }253 }
248- receive(t, scenes)254+ scene(t, scenes)
249 found := false255 found := false
250 for len(commands) > 0 {256 for len(commands) > 0 {
251 if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {257 if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 {
@@ -272,7 +278,7 @@ func TestBorderInset(t *testing.T) {
272 input := make(chan ports.InputEvent, 8)278 input := make(chan ports.InputEvent, 8)
273 output := make(chan ports.OutputEvent, 8)279 output := make(chan ports.OutputEvent, 8)
274 commands := make(chan ports.ClientCommand, 64)280 commands := make(chan ports.ClientCommand, 64)
275- scenes := make(chan ports.Scene, 1)281+ scenes := make(chan []ports.Scene, 1)
276 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})282 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
277 if err != nil {283 if err != nil {
278 t.Fatal(err)284 t.Fatal(err)
@@ -280,23 +286,23 @@ func TestBorderInset(t *testing.T) {
280 ctx, cancel := context.WithCancel(context.Background())286 ctx, cancel := context.WithCancel(context.Background())
281 defer cancel()287 defer cancel()
282 go c.Run(ctx)288 go c.Run(ctx)
283- output <- ports.OutputMode{Width: 100, Height: 80}289+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
284- receive(t, scenes)290+ scene(t, scenes)
285 // Alone, the window is borderless and gets the full rect.291 // Alone, the window is borderless and gets the full rect.
286 client <- ports.WindowMapped{ID: 1}292 client <- ports.WindowMapped{ID: 1}
287- r := receive(t, scenes).Windows[0].Rect293+ r := scene(t, scenes).Windows[0].Rect
288- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) {294+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) {
289 t.Fatalf("%v for outer %v", v, r)295 t.Fatalf("%v for outer %v", v, r)
290 }296 }
291 // With a second column, the scene keeps the outer rect and the client is297 // With a second column, the scene keeps the outer rect and the client is
292 // configured 2px smaller on each side.298 // configured 2px smaller on each side.
293 client <- ports.WindowMapped{ID: 2}299 client <- ports.WindowMapped{ID: 2}
294- s := receive(t, scenes)300+ s := scene(t, scenes)
295 r = s.Windows[1].Rect301 r = s.Windows[1].Rect
296 if s.Windows[1].ID != 2 || s.Windows[1].Borderless {302 if s.Windows[1].ID != 2 || s.Windows[1].Borderless {
297 t.Fatal(s)303 t.Fatal(s)
298 }304 }
299- want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true}305+ want := ports.ConfigureWindow{ID: 2, Width: r.W - 4, Height: r.H - 4, Activated: true, Output: "OUT-1"}
300 found := false306 found := false
301 for len(commands) > 0 {307 for len(commands) > 0 {
302 if v := <-commands; v == want {308 if v := <-commands; v == want {
@@ -318,7 +324,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
318 input := make(chan ports.InputEvent, 8)324 input := make(chan ports.InputEvent, 8)
319 output := make(chan ports.OutputEvent, 8)325 output := make(chan ports.OutputEvent, 8)
320 commands := make(chan ports.ClientCommand, 64)326 commands := make(chan ports.ClientCommand, 64)
321- scenes := make(chan ports.Scene, 1)327+ scenes := make(chan []ports.Scene, 1)
322 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})328 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
323 if err != nil {329 if err != nil {
324 t.Fatal(err)330 t.Fatal(err)
@@ -326,10 +332,10 @@ func TestLayerKeyboardFocus(t *testing.T) {
326 ctx, cancel := context.WithCancel(context.Background())332 ctx, cancel := context.WithCancel(context.Background())
327 defer cancel()333 defer cancel()
328 go c.Run(ctx)334 go c.Run(ctx)
329- output <- ports.OutputMode{Width: 100, Height: 80}335+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
330- receive(t, scenes)336+ scene(t, scenes)
331 client <- ports.WindowMapped{ID: 1}337 client <- ports.WindowMapped{ID: 1}
332- receive(t, scenes)338+ scene(t, scenes)
333 focusOf := func() ports.WindowID {339 focusOf := func() ports.WindowID {
334 t.Helper()340 t.Helper()
335 for {341 for {
@@ -345,7 +351,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
345 launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}351 launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1}
346 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}352 bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2}
347 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}353 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}}
348- receive(t, scenes)354+ scene(t, scenes)
349 if id := focusOf(); id != 7 {355 if id := focusOf(); id != 7 {
350 t.Fatalf("layer focus %d", id)356 t.Fatalf("layer focus %d", id)
351 }357 }
@@ -359,7 +365,7 @@ func TestLayerKeyboardFocus(t *testing.T) {
359 }365 }
360 }366 }
361 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}367 client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
362- receive(t, scenes)368+ scene(t, scenes)
363 if id := focusOf(); id != 1 {369 if id := focusOf(); id != 1 {
364 t.Fatalf("focus not restored: %d", id)370 t.Fatalf("focus not restored: %d", id)
365 }371 }
@@ -371,7 +377,7 @@ func TestWorkspaceSwitch(t *testing.T) {
371 input := make(chan ports.InputEvent, 8)377 input := make(chan ports.InputEvent, 8)
372 output := make(chan ports.OutputEvent, 8)378 output := make(chan ports.OutputEvent, 8)
373 commands := make(chan ports.ClientCommand, 64)379 commands := make(chan ports.ClientCommand, 64)
374- scenes := make(chan ports.Scene, 1)380+ scenes := make(chan []ports.Scene, 1)
375 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})381 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
376 if err != nil {382 if err != nil {
377 t.Fatal(err)383 t.Fatal(err)
@@ -379,16 +385,16 @@ func TestWorkspaceSwitch(t *testing.T) {
379 ctx, cancel := context.WithCancel(context.Background())385 ctx, cancel := context.WithCancel(context.Background())
380 defer cancel()386 defer cancel()
381 go c.Run(ctx)387 go c.Run(ctx)
382- output <- ports.OutputMode{Width: 100, Height: 80}388+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
383- receive(t, scenes)389+ scene(t, scenes)
384 client <- ports.WindowMapped{ID: 1}390 client <- ports.WindowMapped{ID: 1}
385- receive(t, scenes)391+ scene(t, scenes)
386 for len(commands) > 0 {392 for len(commands) > 0 {
387 <-commands393 <-commands
388 }394 }
389 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).395 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
390 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}396 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
391- s := receive(t, scenes)397+ s := scene(t, scenes)
392 if len(s.Windows) != 1 || !s.Windows[0].Hidden {398 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
393 t.Fatal(s)399 t.Fatal(s)
394 }400 }
@@ -407,7 +413,7 @@ func TestWorkspaceSwitch(t *testing.T) {
407 }413 }
408 // A new window opens on workspace 2, alone and borderless.414 // A new window opens on workspace 2, alone and borderless.
409 client <- ports.WindowMapped{ID: 2}415 client <- ports.WindowMapped{ID: 2}
410- s = receive(t, scenes)416+ s = scene(t, scenes)
411 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {417 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless {
412 t.Fatal(s)418 t.Fatal(s)
413 }419 }
@@ -419,7 +425,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
419 input := make(chan ports.InputEvent, 8)425 input := make(chan ports.InputEvent, 8)
420 output := make(chan ports.OutputEvent, 8)426 output := make(chan ports.OutputEvent, 8)
421 commands := make(chan ports.ClientCommand, 64)427 commands := make(chan ports.ClientCommand, 64)
422- scenes := make(chan ports.Scene, 1)428+ scenes := make(chan []ports.Scene, 1)
423 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})429 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
424 if err != nil {430 if err != nil {
425 t.Fatal(err)431 t.Fatal(err)
@@ -427,10 +433,10 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
427 ctx, cancel := context.WithCancel(context.Background())433 ctx, cancel := context.WithCancel(context.Background())
428 defer cancel()434 defer cancel()
429 go c.Run(ctx)435 go c.Run(ctx)
430- output <- ports.OutputMode{Width: 100, Height: 80}436+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
431- receive(t, scenes)437+ scene(t, scenes)
432 client <- ports.WindowMapped{ID: 1}438 client <- ports.WindowMapped{ID: 1}
433- receive(t, scenes)439+ scene(t, scenes)
434 input <- ports.PointerMotion{X: 50, Y: 40}440 input <- ports.PointerMotion{X: 50, Y: 40}
435 for len(commands) < 3 {441 for len(commands) < 3 {
436 time.Sleep(time.Millisecond)442 time.Sleep(time.Millisecond)
@@ -440,7 +446,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
440 }446 }
441 // Cmd+2 hides window 1; the pointer must leave it.447 // Cmd+2 hides window 1; the pointer must leave it.
442 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}448 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
443- receive(t, scenes)449+ scene(t, scenes)
444 var cleared bool450 var cleared bool
445 for len(commands) > 0 {451 for len(commands) > 0 {
446 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {452 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
@@ -454,7 +460,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
454 input <- ports.PointerButton{Button: 0x110, Pressed: true}460 input <- ports.PointerButton{Button: 0x110, Pressed: true}
455 input <- ports.PointerButton{Button: 0x110}461 input <- ports.PointerButton{Button: 0x110}
456 client <- ports.WindowMapped{ID: 2}462 client <- ports.WindowMapped{ID: 2}
457- s := receive(t, scenes)463+ s := scene(t, scenes)
458 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {464 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
459 t.Fatal(s)465 t.Fatal(s)
460 }466 }
@@ -470,7 +476,7 @@ func TestShiftReleasedFirst(t *testing.T) {
470 input := make(chan ports.InputEvent, 8)476 input := make(chan ports.InputEvent, 8)
471 client := make(chan ports.ClientEvent, 2)477 client := make(chan ports.ClientEvent, 2)
472 commands := make(chan ports.ClientCommand, 16)478 commands := make(chan ports.ClientCommand, 16)
473- scenes := make(chan ports.Scene, 1)479+ scenes := make(chan []ports.Scene, 1)
474 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})480 c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes})
475 if err != nil {481 if err != nil {
476 t.Fatal(err)482 t.Fatal(err)
@@ -479,13 +485,13 @@ func TestShiftReleasedFirst(t *testing.T) {
479 defer cancel()485 defer cancel()
480 go c.Run(ctx)486 go c.Run(ctx)
481 client <- ports.WindowMapped{ID: 1}487 client <- ports.WindowMapped{ID: 1}
482- receive(t, scenes)488+ scene(t, scenes)
483 for len(commands) > 0 {489 for len(commands) > 0 {
484 <-commands490 <-commands
485 }491 }
486 // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1.492 // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1.
487 input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}493 input <- ports.KeyEvent{Keysym: "exclam", Base: "1", Keycode: 2, Mods: ports.ModSuper | ports.ModShift, Pressed: true}
488- receive(t, scenes)494+ scene(t, scenes)
489 // Shift goes up first, so the release reports "1": still swallowed.495 // Shift goes up first, so the release reports "1": still swallowed.
490 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}496 input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper}
491 input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}497 input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true}
@@ -505,7 +511,7 @@ func TestOutputScale(t *testing.T) {
505 output := make(chan ports.OutputEvent, 8)511 output := make(chan ports.OutputEvent, 8)
506 reload := make(chan ports.ConfigChanged, 1)512 reload := make(chan ports.ConfigChanged, 1)
507 commands := make(chan ports.ClientCommand, 64)513 commands := make(chan ports.ClientCommand, 64)
508- scenes := make(chan ports.Scene, 1)514+ scenes := make(chan []ports.Scene, 1)
509 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes})515 c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes})
510 if err != nil {516 if err != nil {
511 t.Fatal(err)517 t.Fatal(err)
@@ -514,53 +520,53 @@ func TestOutputScale(t *testing.T) {
514 defer cancel()520 defer cancel()
515 go c.Run(ctx)521 go c.Run(ctx)
516 // 5120x2160 at scale 2 is 2560x1080 logical.522 // 5120x2160 at scale 2 is 2560x1080 logical.
517- output <- ports.OutputMode{Width: 5120, Height: 2160, Name: "DP-2"}523+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}}
518- s := receive(t, scenes)524+ s := scene(t, scenes)
519 if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 {525 if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 {
520 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)526 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
521 }527 }
522- if v := receive(t, commands); v != (ports.SetOutputScale{Scale: 2, Width: 2560, Height: 1080}) {528+ if v := receive(t, commands).(ports.SetOutputs); len(v.Outputs) != 1 || v.Outputs[0].Scale != 2 || v.Outputs[0].Width != 2560 || v.Outputs[0].Height != 1080 || v.Focused != "DP-2" {
523 t.Fatal(v)529 t.Fatal(v)
524 }530 }
525 client <- ports.WindowMapped{ID: 1}531 client <- ports.WindowMapped{ID: 1}
526- receive(t, scenes)532+ scene(t, scenes)
527- if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true}) {533+ if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) {
528 t.Fatal(v)534 t.Fatal(v)
529 }535 }
530 for len(commands) > 0 {536 for len(commands) > 0 {
531 <-commands537 <-commands
532 }538 }
533- // The pointer arrives in physical pixels; the client sees logical ones.539+ // The pointer arrives in global logical pixels.
534- input <- ports.PointerMotion{X: 1000, Y: 400}540+ input <- ports.PointerMotion{X: 500, Y: 200}
535 if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {541 if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) {
536 t.Fatal(v)542 t.Fatal(v)
537 }543 }
538 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.544 // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3.
539 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}545 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true}
540- s = receive(t, scenes)546+ s = scene(t, scenes)
541 if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 {547 if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 {
542 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)548 t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight)
543 }549 }
544 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper}550 input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper}
545 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}551 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
546- if s = receive(t, scenes); s.Scale != 2 {552+ if s = scene(t, scenes); s.Scale != 2 {
547 t.Fatal(s.Scale)553 t.Fatal(s.Scale)
548 }554 }
549 // A reload that keeps the config scale keeps the live one too.555 // A reload that keeps the config scale keeps the live one too.
550 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper}556 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper}
551 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}557 input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper, Pressed: true}
552- if s = receive(t, scenes); s.Scale != 2.5 {558+ if s = scene(t, scenes); s.Scale != 2.5 {
553 t.Fatal(s.Scale)559 t.Fatal(s.Scale)
554 }560 }
555 cfg.Layout.Gaps = 1561 cfg.Layout.Gaps = 1
556 reload <- ports.ConfigChanged{Config: cfg}562 reload <- ports.ConfigChanged{Config: cfg}
557- if s = receive(t, scenes); s.Scale != 2.5 {563+ if s = scene(t, scenes); s.Scale != 2.5 {
558 t.Fatal(s.Scale)564 t.Fatal(s.Scale)
559 }565 }
560 // Changing the config scale wins.566 // Changing the config scale wins.
561 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}}567 cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}}
562 reload <- ports.ConfigChanged{Config: cfg}568 reload <- ports.ConfigChanged{Config: cfg}
563- if s = receive(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 {569+ if s = scene(t, scenes); s.Scale != 1.25 || s.OutputWidth != 4096 {
564 t.Fatal(s.Scale, s.OutputWidth)570 t.Fatal(s.Scale, s.OutputWidth)
565 }571 }
566 }572 }
M internal/core/layers_test.go +3 -3
@@ -53,7 +53,7 @@ func TestLayerChangedChannels(t *testing.T) {
53 client := make(chan ports.ClientEvent)53 client := make(chan ports.ClientEvent)
54 output := make(chan ports.OutputEvent)54 output := make(chan ports.OutputEvent)
55 commands := make(chan ports.ClientCommand, 16)55 commands := make(chan ports.ClientCommand, 16)
56- scenes := make(chan ports.Scene, 1)56+ scenes := make(chan []ports.Scene, 1)
57 cfg := ports.Config{}57 cfg := ports.Config{}
58 cfg.Keyboard.CmdKey = "super"58 cfg.Keyboard.CmdKey = "super"
59 cfg.Layout.MaxColumns = 259 cfg.Layout.MaxColumns = 2
@@ -69,13 +69,13 @@ func TestLayerChangedChannels(t *testing.T) {
69 t.Helper()69 t.Helper()
70 select {70 select {
71 case s := <-scenes:71 case s := <-scenes:
72- return s72+ return s[0]
73 case <-time.After(time.Second):73 case <-time.After(time.Second):
74 t.Fatal("scene timeout")74 t.Fatal("scene timeout")
75 return ports.Scene{}75 return ports.Scene{}
76 }76 }
77 }77 }
78- output <- ports.OutputMode{Width: 1920, Height: 1080}78+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 1920, Height: 1080}}
79 recv()79 recv()
80 client <- ports.WindowMapped{ID: 1}80 client <- ports.WindowMapped{ID: 1}
81 before := recv()81 before := recv()
M internal/core/layout.go +5 -0
@@ -120,6 +120,11 @@ type Workspace struct {
120 MaxColumns int120 MaxColumns int
121 presets []Width121 presets []Width
122 fullscreen WindowID122 fullscreen WindowID
123+ // home is the monitor (key or connector) the workspace belongs to; ""
124+ // means the one it is on. On another monitor it is a guest; homePos is
125+ // its position there, where it returns.
126+ home string
127+ homePos int
123 }128 }
124 129
125 func (w *Workspace) empty() bool { return len(w.Columns) == 0 }130 func (w *Workspace) empty() bool { return len(w.Columns) == 0 }
M internal/core/monitor.go +76 -3
@@ -1,5 +1,7 @@
1 package core1 package core
2 2
3+import "slices"
4+
3 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers5 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
4 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits6 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
5 // below the last one, and an empty unnamed workspace is removed once left,7 // below the last one, and an empty unnamed workspace is removed once left,
@@ -9,6 +11,10 @@ package core
9 // A hidden one is not numbered and not reachable by up/down: only its11 // A hidden one is not numbered and not reachable by up/down: only its
10 // `workspace <name>` bind shows it.12 // `workspace <name>` bind shows it.
11 type Monitor struct {13 type Monitor struct {
14+ // Name is the connector; Key identifies the physical monitor (make,
15+ // model and serial, else the connector). Workspaces remember the Key of
16+ // their home monitor.
17+ Name, Key string
12 Workspaces []*Workspace18 Workspaces []*Workspace
13 Active int19 Active int
14 hidden []*Workspace20 hidden []*Workspace
@@ -21,18 +27,84 @@ type Monitor struct {
21 // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty27 // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
22 // Overflow follow the monitor defaults.28 // Overflow follow the monitor defaults.
23 type NamedWorkspace struct {29 type NamedWorkspace struct {
24- Name string30+ Name string
31+ // Monitor is the home monitor (connector or key); "" for the first one.
32+ Monitor string
25 Hidden bool33 Hidden bool
26 MaxColumns int34 MaxColumns int
27 Overflow Overflow35 Overflow Overflow
28 }36 }
29 37
30-func NewMonitor() *Monitor {38+func NewMonitor() *Monitor { return newMonitor("", "") }
31- m := &Monitor{}39+
40+func newMonitor(name, key string) *Monitor {
41+ m := &Monitor{Name: name, Key: key}
32 m.normalize()42 m.normalize()
33 return m43 return m
34 }44 }
35 45
46+// matches reports whether home designates this monitor, by key or connector.
47+func (m *Monitor) matches(home string) bool {
48+ return home != "" && (home == m.Key || home == m.Name)
49+}
50+
51+// all lists every workspace, numbered then hidden.
52+func (m *Monitor) all() []*Workspace {
53+ return append(append([]*Workspace(nil), m.Workspaces...), m.hidden...)
54+}
55+
56+// isHidden reports whether w is in the hidden list.
57+func (m *Monitor) isHidden(w *Workspace) bool { return indexOf(m.hidden, w) >= 0 }
58+
59+// take removes w from the monitor. The monitor keeps a workspace on screen:
60+// the one at the same position, or the numbered active one.
61+func (m *Monitor) take(w *Workspace) {
62+ if m.back == w {
63+ m.back = nil
64+ }
65+ if i := indexOf(m.hidden, w); i >= 0 {
66+ m.hidden = append(m.hidden[:i], m.hidden[i+1:]...)
67+ if m.shown == w {
68+ m.shown = nil
69+ }
70+ m.normalize()
71+ return
72+ }
73+ if i := indexOf(m.Workspaces, w); i >= 0 {
74+ m.removeNumbered(i)
75+ m.normalize()
76+ }
77+}
78+
79+// adopt adds a workspace from another monitor: hidden, or numbered at pos
80+// (clamped above the trailing empty workspace). It takes this monitor's
81+// output-wide settings and does not change what is on screen.
82+func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
83+ w.presets = append([]Width(nil), m.template.presets...)
84+ w.Gaps = m.template.Gaps
85+ w.SetOutput(m.template.Output.W, m.template.Output.H)
86+ w.SetUsable(m.template.Usable)
87+ if hidden {
88+ m.hidden = append(m.hidden, w)
89+ } else {
90+ // normalize keeps a trailing empty workspace: stay above it.
91+ at := min(max(pos, 0), len(m.Workspaces)-1)
92+ m.Workspaces = slices.Insert(m.Workspaces, at, w)
93+ if at <= m.Active {
94+ m.Active++
95+ }
96+ }
97+ m.applyNamed()
98+ m.normalize()
99+}
100+
101+// useSpecs sets the overrides of every named workspace, including those
102+// that live on other monitors and may move here.
103+func (m *Monitor) useSpecs(all []NamedWorkspace) {
104+ m.named = append([]NamedWorkspace(nil), all...)
105+ m.applyNamed()
106+}
107+
36 // Current is the workspace on screen.108 // Current is the workspace on screen.
37 func (m *Monitor) Current() *Workspace {109 func (m *Monitor) Current() *Workspace {
38 if m.shown != nil {110 if m.shown != nil {
@@ -44,6 +116,7 @@ func (m *Monitor) Current() *Workspace {
44 func (m *Monitor) newWorkspace() *Workspace {116 func (m *Monitor) newWorkspace() *Workspace {
45 w := m.template117 w := m.template
46 w.presets = append([]Width(nil), m.template.presets...)118 w.presets = append([]Width(nil), m.template.presets...)
119+ w.Columns, w.home = nil, ""
47 return &w120 return &w
48 }121 }
49 122
A internal/core/screens.go +308 -0
@@ -0,0 +1,308 @@
1+package core
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+// screen is one connected output: its monitor tree, scale, layer surfaces and
10+// place in the global layout (ADR 011). Outputs are placed left to right, in
11+// config order (output.<name>.*), then in connection order.
12+type screen struct {
13+ info ports.OutputInfo
14+ mon *Monitor
15+ scale, cfgScale float64
16+ x int // logical left edge in the global layout
17+ layers []ports.LayerSurface
18+ placed []ports.SceneLayer
19+}
20+
21+func (s *screen) name() string { return s.info.Name }
22+
23+func (s *screen) placement() ports.OutputPlacement {
24+ o := s.mon.Output()
25+ return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale}
26+}
27+
28+// setScale resizes the logical output; layer placement follows.
29+func (s *screen) setScale(v float64) {
30+ s.scale = SnapScale(v)
31+ s.mon.SetOutput(logical(s.info.Width, s.scale), logical(s.info.Height, s.scale))
32+ s.arrange()
33+}
34+
35+// arrange places the layer surfaces and gives workspaces the usable area.
36+func (s *screen) arrange() {
37+ var usable ports.Rect
38+ o := s.mon.Output()
39+ s.placed, usable = arrangeLayers(o.W, o.H, s.layers)
40+ s.mon.SetUsable(usable)
41+}
42+
43+// order sorts screens by config order, then connection order, and assigns x.
44+func (c *Core) order() {
45+ rank := func(s *screen) int {
46+ for i, o := range c.cfg.Outputs {
47+ if o.Name == s.name() {
48+ return i
49+ }
50+ }
51+ return len(c.cfg.Outputs)
52+ }
53+ focused := c.cur()
54+ slices.SortStableFunc(c.screens, func(a, b *screen) int { return rank(a) - rank(b) })
55+ c.focusScreen = slices.Index(c.screens, focused)
56+ x := 0
57+ for _, s := range c.screens {
58+ s.x = x
59+ x += s.mon.Output().W
60+ }
61+}
62+
63+// layout lists the connected outputs; the placeholder is not one.
64+func (c *Core) layout() ports.Layout {
65+ l := make(ports.Layout, 0, len(c.screens))
66+ for _, s := range c.screens {
67+ if s.name() != "" {
68+ l = append(l, s.placement())
69+ }
70+ }
71+ return l
72+}
73+
74+// cur is the focused screen: new windows open there and binds apply to it.
75+func (c *Core) cur() *screen { return c.screens[c.focusScreen] }
76+
77+func (c *Core) screenIndex(name string) int {
78+ return slices.IndexFunc(c.screens, func(s *screen) bool { return s.name() == name })
79+}
80+
81+// screenOf returns the screen and workspace holding the window.
82+func (c *Core) screenOf(id WindowID) (*screen, *Workspace) {
83+ for _, s := range c.screens {
84+ if w, _ := s.mon.find(id); w != nil {
85+ return s, w
86+ }
87+ }
88+ return nil, nil
89+}
90+
91+// byName finds a named workspace on any screen.
92+func (c *Core) byName(name string) (*screen, *Workspace) {
93+ for _, s := range c.screens {
94+ if w := s.mon.byName(name); w != nil {
95+ return s, w
96+ }
97+ }
98+ return nil, nil
99+}
100+
101+// configScale is output.<name>.scale, else 1.
102+func (c *Core) configScale(name string) float64 {
103+ v := 1.0
104+ for _, o := range c.cfg.Outputs {
105+ if o.Name == name && o.Scale != 0 {
106+ v = o.Scale
107+ }
108+ }
109+ return SnapScale(v)
110+}
111+
112+// applyConfigScales picks up output.<name>.scale when it changed in the
113+// config, replacing any live scale-up/scale-down adjustment.
114+func (c *Core) applyConfigScales() {
115+ for _, s := range c.screens {
116+ if v := c.configScale(s.name()); v != s.cfgScale {
117+ s.cfgScale = v
118+ s.setScale(v)
119+ }
120+ }
121+ c.order()
122+}
123+
124+// addScreen handles a connected output or a new mode for a known one. The
125+// first output takes over the placeholder screen core starts with; a new
126+// monitor gets its named workspaces and takes its guests back.
127+func (c *Core) addScreen(info ports.OutputInfo) {
128+ if i := c.screenIndex(info.Name); i >= 0 {
129+ s := c.screens[i]
130+ s.info = info
131+ s.setScale(s.scale)
132+ c.order()
133+ return
134+ }
135+ var s *screen
136+ if len(c.screens) == 1 && c.screens[0].name() == "" {
137+ s = c.screens[0]
138+ s.info = info
139+ } else {
140+ s = &screen{info: info, mon: newMonitor(info.Name, info.Key())}
141+ c.settings(s.mon)
142+ c.screens = append(c.screens, s)
143+ }
144+ s.mon.Name, s.mon.Key = info.Name, info.Key()
145+ s.cfgScale = c.configScale(info.Name)
146+ s.setScale(s.cfgScale)
147+ c.order()
148+ c.named()
149+ c.settleGuests()
150+}
151+
152+// removeScreen moves every workspace of an unplugged output to the focused
153+// screen (another one if it was focused), numbered ones below its numbered
154+// workspaces, hidden ones hidden. They remember their home and position and
155+// return when it is plugged in again. The last output becomes the
156+// placeholder again: it keeps its windows for the next output.
157+func (c *Core) removeScreen(name string) {
158+ i := c.screenIndex(name)
159+ if i < 0 {
160+ return
161+ }
162+ if len(c.screens) == 1 {
163+ c.screens[0].info = ports.OutputInfo{}
164+ c.screens[0].layers = nil
165+ return
166+ }
167+ gone := c.screens[i]
168+ focused := c.cur()
169+ c.screens = slices.Delete(c.screens, i, i+1)
170+ c.focusScreen = max(slices.Index(c.screens, focused), 0)
171+ host := c.cur()
172+ for pos, w := range gone.mon.Workspaces {
173+ if w.empty() && w.Name == "" {
174+ continue
175+ }
176+ switch {
177+ case host.mon.matches(w.home):
178+ // A guest from the host goes home.
179+ w.home = ""
180+ host.mon.adopt(w, false, w.homePos)
181+ continue
182+ case w.home == "":
183+ w.home, w.homePos = gone.info.Key(), pos
184+ }
185+ host.mon.adopt(w, false, len(host.mon.Workspaces))
186+ }
187+ for _, w := range gone.mon.hidden {
188+ switch {
189+ case host.mon.matches(w.home):
190+ w.home = ""
191+ case w.home == "":
192+ w.home = gone.info.Key()
193+ }
194+ host.mon.adopt(w, true, 0)
195+ }
196+ c.order()
197+}
198+
199+// settleGuests returns guests to their connected home monitor, at their
200+// original positions. A guest on screen stays until the user leaves it
201+// (golden rule: automatic events never move the user).
202+func (c *Core) settleGuests() {
203+ for _, home := range c.screens {
204+ type guest struct {
205+ w *Workspace
206+ hidden bool
207+ }
208+ var list []guest
209+ for _, host := range c.screens {
210+ if host == home {
211+ continue
212+ }
213+ for _, w := range host.mon.all() {
214+ if home.mon.matches(w.home) && host.mon.Current() != w {
215+ list = append(list, guest{w, host.mon.isHidden(w)})
216+ host.mon.take(w)
217+ }
218+ }
219+ }
220+ slices.SortStableFunc(list, func(a, b guest) int { return a.w.homePos - b.w.homePos })
221+ // A monitor showing an empty workspace (just plugged in) shows
222+ // its first returning workspace instead.
223+ idle := home.mon.Current().empty() && home.mon.Current().Name == ""
224+ for _, g := range list {
225+ g.w.home = ""
226+ home.mon.adopt(g.w, g.hidden, g.w.homePos)
227+ if idle && !g.hidden {
228+ home.mon.show(g.w)
229+ idle = false
230+ }
231+ }
232+ }
233+}
234+
235+// named applies the named workspaces from config. One that exists stays on
236+// its screen; a new one is created on its home monitor, or on the first
237+// screen as a guest when its home is not connected.
238+func (c *Core) named() {
239+ target := map[string]*screen{}
240+ for _, spec := range c.specs {
241+ if s, _ := c.byName(spec.Name); s != nil {
242+ target[spec.Name] = s
243+ continue
244+ }
245+ target[spec.Name] = c.screens[0]
246+ for _, s := range c.screens {
247+ if s.mon.matches(spec.Monitor) {
248+ target[spec.Name] = s
249+ }
250+ }
251+ }
252+ for _, s := range c.screens {
253+ var mine []NamedWorkspace
254+ for _, spec := range c.specs {
255+ if target[spec.Name] == s {
256+ mine = append(mine, spec)
257+ }
258+ }
259+ s.mon.SetNamed(mine)
260+ s.mon.useSpecs(c.specs)
261+ }
262+ for _, spec := range c.specs {
263+ s, w := c.byName(spec.Name)
264+ w.home = ""
265+ if spec.Monitor != "" && !s.mon.matches(spec.Monitor) {
266+ w.home = spec.Monitor
267+ }
268+ }
269+}
270+
271+// settings applies output-wide config to a monitor.
272+func (c *Core) settings(m *Monitor) {
273+ m.SetOverflow(c.overflow)
274+ m.SetMaxColumns(c.cfg.Layout.MaxColumns)
275+ m.SetPresets(c.presets)
276+ m.SetGaps(c.cfg.Layout.Gaps)
277+}
278+
279+// neighbor returns the screen index left (-1) or right (+1) of the focused
280+// one, or -1 at the edge.
281+func (c *Core) neighbor(dir int) int {
282+ i := c.focusScreen + dir
283+ if i < 0 || i >= len(c.screens) {
284+ return -1
285+ }
286+ return i
287+}
288+
289+// moveWorkspace moves the workspace on screen to the neighbor screen, where
290+// it is appended and shown; it becomes its new home (ADR 011).
291+func (c *Core) moveWorkspace(dir int) {
292+ to := c.neighbor(dir)
293+ if to < 0 {
294+ return
295+ }
296+ from := c.cur()
297+ w := from.mon.Current()
298+ if w.empty() && w.Name == "" {
299+ return
300+ }
301+ hidden := from.mon.isHidden(w)
302+ from.mon.take(w)
303+ w.home = ""
304+ dst := c.screens[to]
305+ dst.mon.adopt(w, hidden, len(dst.mon.Workspaces))
306+ dst.mon.show(w)
307+ c.focusScreen = to
308+}
A internal/core/screens_test.go +326 -0
@@ -0,0 +1,326 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/adapters/config"
8+ "github.com/bnema/nefertty/internal/core"
9+ "github.com/bnema/nefertty/internal/ports"
10+)
11+
12+type multiRig struct {
13+ client chan ports.ClientEvent
14+ input chan ports.InputEvent
15+ output chan ports.OutputEvent
16+ reload chan ports.ConfigChanged
17+ commands chan ports.ClientCommand
18+ scenes chan []ports.Scene
19+ cfg ports.Config
20+}
21+
22+var (
23+ left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
24+ right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
25+)
26+
27+// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
28+// the given outputs in order.
29+func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
30+ t.Helper()
31+ cfg := config.Defaults()
32+ cfg.Keyboard.CmdKey = "alt"
33+ cfg.Border.Width = 0
34+ if edit != nil {
35+ edit(&cfg)
36+ }
37+ r := &multiRig{
38+ client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39+ output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
40+ commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), cfg: cfg,
41+ }
42+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ ctx, cancel := context.WithCancel(context.Background())
47+ done := make(chan error, 1)
48+ go func() { done <- c.Run(ctx) }()
49+ t.Cleanup(func() { cancel(); <-done })
50+ for _, o := range outs {
51+ r.plug(t, o)
52+ }
53+ return r
54+}
55+
56+func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
57+ t.Helper()
58+ r.output <- ports.OutputAdded{Info: o}
59+ return receive(t, r.scenes)
60+}
61+
62+func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
63+ t.Helper()
64+ r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
65+ s := receive(t, r.scenes)
66+ r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
67+ return s
68+}
69+
70+func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
71+ t.Helper()
72+ r.client <- ports.WindowMapped{ID: id}
73+ return receive(t, r.scenes)
74+}
75+
76+// shown returns the visible window IDs of each output, by connector.
77+func shown(set []ports.Scene) map[string][]ports.WindowID {
78+ out := map[string][]ports.WindowID{}
79+ for _, s := range set {
80+ out[s.Output] = []ports.WindowID{}
81+ for _, w := range s.Windows {
82+ if !w.Hidden {
83+ out[s.Output] = append(out[s.Output], w.ID)
84+ }
85+ }
86+ }
87+ return out
88+}
89+
90+func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
91+ t.Helper()
92+ var last ports.SetOutputs
93+ found := false
94+ for len(ch) > 0 {
95+ if v, ok := (<-ch).(ports.SetOutputs); ok {
96+ last, found = v, true
97+ }
98+ }
99+ if !found {
100+ t.Fatal("no SetOutputs")
101+ }
102+ return last
103+}
104+
105+func TestOutputsLeftToRight(t *testing.T) {
106+ r := startMulti(t, func(c *ports.Config) {
107+ c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
108+ }, left, right)
109+ out := lastOutputs(t, r.commands)
110+ // Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
111+ if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
112+ out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
113+ t.Fatalf("%+v", out)
114+ }
115+}
116+
117+func TestWindowsOpenOnFocusedOutput(t *testing.T) {
118+ r := startMulti(t, nil, left, right)
119+ r.mapWindow(t, 1)
120+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
121+ set := r.mapWindow(t, 2)
122+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
123+ t.Fatal(got)
124+ }
125+ // Only the focused output draws a focused window.
126+ for _, s := range set {
127+ for _, w := range s.Windows {
128+ if w.Focused != (s.Output == "DP-2") {
129+ t.Fatalf("%s: %+v", s.Output, w)
130+ }
131+ }
132+ }
133+}
134+
135+func TestFocusColumnCrossesOutputs(t *testing.T) {
136+ r := startMulti(t, nil, left, right)
137+ r.mapWindow(t, 1)
138+ // The only column is the edge: focus moves to the right output.
139+ r.key(t, "Right", ports.ModAlt)
140+ r.mapWindow(t, 2)
141+ for len(r.commands) > 0 {
142+ <-r.commands
143+ }
144+ r.key(t, "Left", ports.ModAlt)
145+ var focus ports.WindowID
146+ for len(r.commands) > 0 {
147+ if v, ok := (<-r.commands).(ports.FocusWindow); ok {
148+ focus = v.ID
149+ }
150+ }
151+ if focus != 1 {
152+ t.Fatal(focus)
153+ }
154+}
155+
156+func TestPointerCrossesOutputs(t *testing.T) {
157+ r := startMulti(t, nil, left, right)
158+ r.mapWindow(t, 1)
159+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
160+ r.mapWindow(t, 2)
161+ for len(r.commands) > 0 {
162+ <-r.commands
163+ }
164+ // DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
165+ r.input <- ports.PointerMotion{X: 250, Y: 50}
166+ if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
167+ t.Fatal(v)
168+ }
169+ // Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
170+ r.input <- ports.PointerMotion{X: 10, Y: 50}
171+ r.input <- ports.PointerMotion{X: 10, Y: 150}
172+ for {
173+ if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
174+ if v.ID != 1 || v.X != 10 {
175+ t.Fatal(v)
176+ }
177+ break
178+ }
179+ }
180+ // A click focuses the output under the pointer.
181+ r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
182+ set := receive(t, r.scenes)
183+ for _, s := range set {
184+ for _, w := range s.Windows {
185+ if w.Focused != (w.ID == 1) {
186+ t.Fatalf("%s: %+v", s.Output, w)
187+ }
188+ }
189+ }
190+}
191+
192+func TestMoveWorkspaceToMonitor(t *testing.T) {
193+ r := startMulti(t, nil, left, right)
194+ r.mapWindow(t, 1)
195+ set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
196+ if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
197+ t.Fatal(got)
198+ }
199+ // It is DP-2's now: unplugging DP-1 does not move it.
200+ r.output <- ports.OutputRemoved{Name: "DP-1"}
201+ set = receive(t, r.scenes)
202+ if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
203+ t.Fatal(shown(set))
204+ }
205+}
206+
207+func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
208+ r := startMulti(t, nil, left, right)
209+ r.mapWindow(t, 1)
210+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
211+ r.mapWindow(t, 2)
212+ // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
213+ r.output <- ports.OutputRemoved{Name: "DP-2"}
214+ set := receive(t, r.scenes)
215+ if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
216+ t.Fatal(shown(set))
217+ }
218+ // Cmd+2 shows the guest.
219+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
220+ set = receive(t, r.scenes)
221+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
222+ if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
223+ t.Fatal(got)
224+ }
225+ // Replugged while on screen: the guest stays until the user leaves it.
226+ set = r.plug(t, right)
227+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
228+ t.Fatal(got)
229+ }
230+ r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
231+ set = receive(t, r.scenes)
232+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
233+ t.Fatal(got)
234+ }
235+}
236+
237+func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
238+ r := startMulti(t, nil, left, right)
239+ r.mapWindow(t, 1)
240+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
241+ r.mapWindow(t, 2)
242+ r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
243+ r.output <- ports.OutputRemoved{Name: "DP-2"}
244+ receive(t, r.scenes)
245+ // Not on screen: it goes home as soon as DP-2 is back.
246+ set := r.plug(t, right)
247+ if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
248+ t.Fatal(got)
249+ }
250+}
251+
252+func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
253+ r := startMulti(t, func(c *ports.Config) {
254+ c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2", Hidden: true}}
255+ c.Binds["Alt+w"] = "workspace web"
256+ }, left)
257+ // DP-2 is not plugged: web lives on DP-1 as a guest.
258+ r.key(t, "w", ports.ModAlt)
259+ r.mapWindow(t, 1)
260+ r.key(t, "w", ports.ModAlt)
261+ // DP-2 arrives: web goes home (not on screen).
262+ r.plug(t, right)
263+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
264+ set := r.key(t, "w", ports.ModAlt)
265+ if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
266+ t.Fatal(got)
267+ }
268+}
269+
270+func TestLayersStayOnTheirOutput(t *testing.T) {
271+ r := startMulti(t, nil, left, right)
272+ bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
273+ r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
274+ set := receive(t, r.scenes)
275+ for _, s := range set {
276+ if (len(s.Layers) == 1) != (s.Output == "DP-2") {
277+ t.Fatalf("%s: %+v", s.Output, s.Layers)
278+ }
279+ }
280+ if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
281+ t.Fatal(set[1].Layers)
282+ }
283+}
284+
285+func TestScaleBindTargetsFocusedOutput(t *testing.T) {
286+ r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
287+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
288+ set := r.key(t, "equal", ports.ModAlt)
289+ if set[0].Scale != 1 || set[1].Scale == 1 {
290+ t.Fatal(set[0].Scale, set[1].Scale)
291+ }
292+}
293+
294+func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
295+ r := startMulti(t, nil, left)
296+ r.mapWindow(t, 1)
297+ r.output <- ports.OutputRemoved{Name: "DP-1"}
298+ if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
299+ t.Fatal(shown(set))
300+ }
301+ if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
302+ t.Fatalf("%+v", out)
303+ }
304+ // Another monitor takes the windows over.
305+ set := r.plug(t, right)
306+ if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
307+ t.Fatal(got)
308+ }
309+}
310+
311+func TestModeChangeKeepsScreen(t *testing.T) {
312+ r := startMulti(t, nil, left, right)
313+ r.mapWindow(t, 1)
314+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
315+ r.mapWindow(t, 2)
316+ // A new mode on DP-2 is only a new size: nothing moves, focus stays.
317+ bigger := right
318+ bigger.Width, bigger.Height = 800, 400
319+ set := r.plug(t, bigger)
320+ if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
321+ t.Fatal(got)
322+ }
323+ if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
324+ t.Fatalf("%+v", out)
325+ }
326+}
M internal/core/slots.go +8 -8
@@ -64,8 +64,8 @@ func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
64 64
65 // updateSlots applies the config and returns the slots to spawn: new ones,65 // updateSlots applies the config and returns the slots to spawn: new ones,
66 // and empty ones whose command changed. A slot dropped from config keeps its66 // and empty ones whose command changed. A slot dropped from config keeps its
67-// window as a normal column. Call before Monitor.SetNamed, so a renamed or67+// window as a normal column. Call before named workspaces are applied, so a
68-// dropped workspace can still be found.68+// renamed or dropped workspace can still be found.
69 func (c *Core) updateSlots(specs []slotSpec) []slotKey {69 func (c *Core) updateSlots(specs []slotSpec) []slotKey {
70 want := map[slotKey]slotSpec{}70 want := map[slotKey]slotSpec{}
71 for _, s := range specs {71 for _, s := range specs {
@@ -75,7 +75,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
75 if _, ok := want[key]; ok {75 if _, ok := want[key]; ok {
76 continue76 continue
77 }77 }
78- if w := c.ws.byName(key.workspace); w != nil {78+ if _, w := c.byName(key.workspace); w != nil {
79 w.unslot(key.index)79 w.unslot(key.index)
80 }80 }
81 delete(c.slots, key)81 delete(c.slots, key)
@@ -90,7 +90,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
90 }90 }
91 if st.width != s.width {91 if st.width != s.width {
92 st.width = s.width92 st.width = s.width
93- if w := c.ws.byName(s.key.workspace); w != nil {93+ if _, w := c.byName(s.key.workspace); w != nil {
94 w.setSlotWidth(s.key.index, s.width)94 w.setSlotWidth(s.key.index, s.width)
95 }95 }
96 }96 }
@@ -122,13 +122,13 @@ func (c *Core) placeSlotWindow(id WindowID, token string) bool {
122 if !st.pending() || st.token != token || st.window != 0 {122 if !st.pending() || st.token != token || st.window != 0 {
123 continue123 continue
124 }124 }
125- w := c.ws.byName(key.workspace)125+ sc, w := c.byName(key.workspace)
126 if w == nil {126 if w == nil {
127 return false127 return false
128 }128 }
129 st.window, st.token = id, ""129 st.window, st.token = id, ""
130 w.AddSlotWindow(id, key.index, st.width)130 w.AddSlotWindow(id, key.index, st.width)
131- c.ws.normalize()131+ sc.mon.normalize()
132 return true132 return true
133 }133 }
134 return false134 return false
@@ -141,7 +141,7 @@ func (c *Core) releaseSlots() {
141 if st.window == 0 {141 if st.window == 0 {
142 continue142 continue
143 }143 }
144- w := c.ws.byName(key.workspace)144+ _, w := c.byName(key.workspace)
145 if w == nil || !w.inSlot(st.window, key.index) {145 if w == nil || !w.inSlot(st.window, key.index) {
146 st.window = 0146 st.window = 0
147 }147 }
@@ -152,7 +152,7 @@ func (c *Core) releaseSlots() {
152 // still pending for the second show in a row is respawned: its command152 // still pending for the second show in a row is respawned: its command
153 // failed, exited or handed off to another instance without a window.153 // failed, exited or handed off to another instance without a window.
154 func (c *Core) refill() []slotKey {154 func (c *Core) refill() []slotKey {
155- name := c.ws.Current().Name155+ name := c.cur().mon.Current().Name
156 if name == "" {156 if name == "" {
157 return nil157 return nil
158 }158 }
M internal/core/slots_test.go +26 -26
@@ -17,7 +17,7 @@ type slotRig struct {
17 input chan ports.InputEvent17 input chan ports.InputEvent
18 reload chan ports.ConfigChanged18 reload chan ports.ConfigChanged
19 spawn chan ports.SpawnRequest19 spawn chan ports.SpawnRequest
20- scenes chan ports.Scene20+ scenes chan []ports.Scene
21 errs chan error21 errs chan error
22 cfg ports.Config22 cfg ports.Config
23 }23 }
@@ -37,7 +37,7 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
37 r := &slotRig{37 r := &slotRig{
38 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),38 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),39 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
40- scenes: make(chan ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,40+ scenes: make(chan []ports.Scene, 1), errs: make(chan error, 4), cfg: cfg,
41 }41 }
42 output := make(chan ports.OutputEvent, 1)42 output := make(chan ports.OutputEvent, 1)
43 commands := make(chan ports.ClientCommand, 1024)43 commands := make(chan ports.ClientCommand, 1024)
@@ -53,8 +53,8 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
53 for range commands {53 for range commands {
54 }54 }
55 }()55 }()
56- output <- ports.OutputMode{Width: 100, Height: 80}56+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
57- receive(t, r.scenes)57+ scene(t, r.scenes)
58 return r58 return r
59 }59 }
60 60
@@ -86,7 +86,7 @@ func (r *slotRig) press(t *testing.T, key string) ports.Scene {
86 t.Helper()86 t.Helper()
87 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}87 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
88 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}88 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
89- return receive(t, r.scenes)89+ return scene(t, r.scenes)
90 }90 }
91 91
92 // sync waits for core to handle everything sent so far.92 // sync waits for core to handle everything sent so far.
@@ -102,19 +102,19 @@ func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
102 code, foot = receive(t, r.spawn), receive(t, r.spawn)102 code, foot = receive(t, r.spawn), receive(t, r.spawn)
103 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}103 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
104 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}104 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
105- scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })105+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
106 return code, foot106 return code, foot
107 }107 }
108 108
109-// scene waits for a scene matching ok.109+// sceneMatch waits for a first-output scene matching ok.
110-func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene {110+func sceneMatch(t *testing.T, ch <-chan []ports.Scene, ok func(ports.Scene) bool) ports.Scene {
111 t.Helper()111 t.Helper()
112 deadline := time.After(time.Second)112 deadline := time.After(time.Second)
113 for {113 for {
114 select {114 select {
115 case s := <-ch:115 case s := <-ch:
116- if ok(s) {116+ if ok(s[0]) {
117- return s117+ return s[0]
118 }118 }
119 case <-deadline:119 case <-deadline:
120 t.Fatal("no matching scene")120 t.Fatal("no matching scene")
@@ -143,7 +143,7 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
143 // foot maps first; code's window still goes left of it.143 // foot maps first; code's window still goes left of it.
144 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}144 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
145 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}145 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
146- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })146+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
147 for _, w := range s.Windows {147 for _, w := range s.Windows {
148 if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {148 if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
149 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)149 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
@@ -163,7 +163,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
163 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}163 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
164 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}164 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
165 r.client <- ports.WindowUnmapped{ID: 3} // foot exits165 r.client <- ports.WindowUnmapped{ID: 3} // foot exits
166- scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })166+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
167 noSpawn(t, r.spawn) // nothing relaunches on its own167 noSpawn(t, r.spawn) // nothing relaunches on its own
168 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}168 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
169 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}169 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
@@ -172,7 +172,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) {
172 t.Fatal(again)172 t.Fatal(again)
173 }173 }
174 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}174 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
175- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })175+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
176 if got := visible(s); got[4].X != 70 {176 if got := visible(s); got[4].X != 70 {
177 t.Fatal(got)177 t.Fatal(got)
178 }178 }
@@ -190,7 +190,7 @@ func TestSlotExtraWindowsAreNormal(t *testing.T) {
190 // A second window of the same process: the slot is taken, normal rules.190 // A second window of the same process: the slot is taken, normal rules.
191 r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}191 r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
192 r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}192 r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
193- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })193+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
194 if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {194 if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
195 t.Fatalf("extra windows not on the workspace on screen: %v", got)195 t.Fatalf("extra windows not on the workspace on screen: %v", got)
196 }196 }
@@ -200,7 +200,7 @@ func TestSlotReload(t *testing.T) {
200 r := startSlots(t)200 r := startSlots(t)
201 code, foot := receive(t, r.spawn), receive(t, r.spawn)201 code, foot := receive(t, r.spawn), receive(t, r.spawn)
202 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}202 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
203- scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })203+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
204 cfg := r.cfg204 cfg := r.cfg
205 ws := cfg.Workspaces[0]205 ws := cfg.Workspaces[0]
206 ws.Slots = []ports.SlotConfig{206 ws.Slots = []ports.SlotConfig{
@@ -218,18 +218,18 @@ func TestSlotReload(t *testing.T) {
218 if !slices.Equal(argv, []string{"btop", "kitty"}) {218 if !slices.Equal(argv, []string{"btop", "kitty"}) {
219 t.Fatal(argv)219 t.Fatal(argv)
220 }220 }
221- receive(t, r.scenes)221+ scene(t, r.scenes)
222 noSpawn(t, r.spawn)222 noSpawn(t, r.spawn)
223 // The old foot spawn no longer fills slot 2 (its command changed): it223 // The old foot spawn no longer fills slot 2 (its command changed): it
224 // opens as a normal window on workspace 1, on screen.224 // opens as a normal window on workspace 1, on screen.
225 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}225 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
226- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })226+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
227 if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {227 if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
228 t.Fatal(got)228 t.Fatal(got)
229 }229 }
230 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}230 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
231 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}231 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
232- s = scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })232+ s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
233 if got := visible(s); got[2].W != 50 {233 if got := visible(s); got[2].W != 50 {
234 t.Fatal(got)234 t.Fatal(got)
235 }235 }
@@ -238,7 +238,7 @@ func TestSlotReload(t *testing.T) {
238 ws.Slots = nil238 ws.Slots = nil
239 cfg.Workspaces = []ports.WorkspaceConfig{ws}239 cfg.Workspaces = []ports.WorkspaceConfig{ws}
240 r.reload <- ports.ConfigChanged{Config: cfg}240 r.reload <- ports.ConfigChanged{Config: cfg}
241- receive(t, r.scenes)241+ scene(t, r.scenes)
242 noSpawn(t, r.spawn)242 noSpawn(t, r.spawn)
243 }243 }
244 244
@@ -271,7 +271,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
271 if v := receive(t, r.spawn); v.Argv[0] != "btop" {271 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
272 t.Fatal(v)272 t.Fatal(v)
273 }273 }
274- receive(t, r.scenes)274+ scene(t, r.scenes)
275 noSpawn(t, r.spawn)275 noSpawn(t, r.spawn)
276 }276 }
277 277
@@ -282,7 +282,7 @@ func TestSlotNotRefilledByOtherBinds(t *testing.T) {
282 r.fill(t)282 r.fill(t)
283 r.press(t, "d")283 r.press(t, "d")
284 r.client <- ports.WindowUnmapped{ID: 3}284 r.client <- ports.WindowUnmapped{ID: 3}
285- scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })285+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
286 r.sync(t)286 r.sync(t)
287 noSpawn(t, r.spawn)287 noSpawn(t, r.spawn)
288 r.press(t, "d")288 r.press(t, "d")
@@ -299,7 +299,7 @@ func TestSlotOldTokenIsNormal(t *testing.T) {
299 _, foot := r.fill(t)299 _, foot := r.fill(t)
300 r.client <- ports.WindowUnmapped{ID: 3}300 r.client <- ports.WindowUnmapped{ID: 3}
301 r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}301 r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
302- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })302+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
303 if got := visible(s); got[9] == (ports.Rect{}) {303 if got := visible(s); got[9] == (ports.Rect{}) {
304 t.Fatalf("old-token window hidden in the slot: %v", got)304 t.Fatalf("old-token window hidden in the slot: %v", got)
305 }305 }
@@ -311,7 +311,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
311 r := startSlots(t)311 r := startSlots(t)
312 code, foot := receive(t, r.spawn), receive(t, r.spawn)312 code, foot := receive(t, r.spawn), receive(t, r.spawn)
313 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}313 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
314- scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })314+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
315 r.press(t, "d") // foot still starting: not respawned315 r.press(t, "d") // foot still starting: not respawned
316 noSpawn(t, r.spawn)316 noSpawn(t, r.spawn)
317 r.press(t, "d")317 r.press(t, "d")
@@ -321,12 +321,12 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
321 t.Fatal(again)321 t.Fatal(again)
322 }322 }
323 r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}323 r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
324- s := scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })324+ s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
325 if got := visible(s); got[7] == (ports.Rect{}) {325 if got := visible(s); got[7] == (ports.Rect{}) {
326 t.Fatalf("stale-token window not placed as a normal one: %v", got)326 t.Fatalf("stale-token window not placed as a normal one: %v", got)
327 }327 }
328 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}328 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
329- s = scene(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })329+ s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
330 if got := visible(s); got[8].W != 30 {330 if got := visible(s); got[8].W != 30 {
331 t.Fatalf("respawned window not in its slot: %v", got)331 t.Fatalf("respawned window not in its slot: %v", got)
332 }332 }
@@ -339,7 +339,7 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) {
339 r.fill(t)339 r.fill(t)
340 r.press(t, "d") // on dev, focus on code (slot 1)340 r.press(t, "d") // on dev, focus on code (slot 1)
341 r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}341 r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
342- receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1342+ scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1
343 noSpawn(t, r.spawn)343 noSpawn(t, r.spawn)
344 r.press(t, "d") // back to workspace 1344 r.press(t, "d") // back to workspace 1
345 r.press(t, "d") // dev again: slot 1 is empty345 r.press(t, "d") // dev again: slot 1 is empty
M internal/ports/config.go +5 -2
@@ -3,8 +3,8 @@ package ports
3 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").3 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the4 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5 // monitor's preferred mode. Off disables the connector.5 // monitor's preferred mode. Off disables the connector.
6-// The first listed, connected, enabled output is used; without a match, the first6+// Every connected output that is not off is used. Outputs are placed left to
7-// connected output that is not off.7+// right in config order, then in connection order.
8 // Scale is the output scale (0 means 1); layout works in logical pixels,8 // Scale is the output scale (0 means 1); layout works in logical pixels,
9 // physical = logical × Scale.9 // physical = logical × Scale.
10 // ScaleOnly marks an entry set by output.<name>.scale alone: it does not10 // ScaleOnly marks an entry set by output.<name>.scale alone: it does not
@@ -60,6 +60,9 @@ type Config struct {
60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.60 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
61 type WorkspaceConfig struct {61 type WorkspaceConfig struct {
62 Name string62 Name string
63+ // Monitor is the home monitor: a connector name (DP-2) or a monitor key
64+ // ("make model serial"); empty means the first output.
65+ Monitor string
63 // Slots are the declared columns (workspace.<name>.column.N), by N.66 // Slots are the declared columns (workspace.<name>.column.N), by N.
64 Slots []SlotConfig67 Slots []SlotConfig
65 Hidden bool68 Hidden bool
M internal/ports/messages.go +86 -19
@@ -61,6 +61,8 @@ type LayerSurface struct {
61 Namespace string61 Namespace string
62 // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.62 // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand.
63 Keyboard uint3263 Keyboard uint32
64+ // Output is the connector the surface is on.
65+ Output string
64 }66 }
65 67
66 // LayerChanged carries wayland → core the full list of mapped layer surfaces,68 // LayerChanged carries wayland → core the full list of mapped layer surfaces,
@@ -102,7 +104,7 @@ type ModState struct{ Depressed, Latched, Locked, Group uint32 }
102 104
103 func (KeyEvent) inputEvent() {}105 func (KeyEvent) inputEvent() {}
104 106
105-// PointerMotion uses absolute output coordinates in physical pixels.107+// PointerMotion uses global layout coordinates in logical pixels (see OutputPlacement).
106 type PointerMotion struct {108 type PointerMotion struct {
107 X, Y float64109 X, Y float64
108 TimeMsec uint32110 TimeMsec uint32
@@ -122,19 +124,36 @@ func (PointerButton) inputEvent() {}
122 // OutputEvent carries output → core notifications.124 // OutputEvent carries output → core notifications.
123 type OutputEvent interface{ outputEvent() }125 type OutputEvent interface{ outputEvent() }
124 126
125-// OutputMode carries output → core resolution changes in physical pixels.127+// OutputInfo describes a connected display. Sizes are physical pixels.
126-// Name is the connector, matched against output.<name>.scale.128+type OutputInfo struct {
127-type OutputMode struct {129+ // Name is the connector (e.g. DP-2), matched against output.<name>.* config.
128- Width, Height int130+ Name string
129- Name string131+ // Make, Model and Serial come from EDID; empty when unknown.
132+ Make, Model, Serial string
133+ Width, Height int
134+ RefreshMilli int
135+ PhysicalW, PhysicalH int // millimetres
136+}
137+
138+// Key identifies the monitor across connectors: make, model and serial, or
139+// the connector name when EDID has no serial.
140+func (i OutputInfo) Key() string {
141+ if i.Serial == "" {
142+ return i.Name
143+ }
144+ return i.Make + " " + i.Model + " " + i.Serial
130 }145 }
131 146
132-func (OutputMode) outputEvent() {}147+// OutputAdded carries output → core a new display, or a new mode for a known
148+// connector.
149+type OutputAdded struct{ Info OutputInfo }
150+
151+func (OutputAdded) outputEvent() {}
133 152
134-// OutputUsable carries output → core usable area after layer-shell exclusive zones.153+// OutputRemoved carries output → core an unplugged display.
135-type OutputUsable struct{ Rect Rect }154+type OutputRemoved struct{ Name string }
136 155
137-func (OutputUsable) outputEvent() {}156+func (OutputRemoved) outputEvent() {}
138 157
139 // ConfigChanged carries config → core reloads.158 // ConfigChanged carries config → core reloads.
140 type ConfigChanged struct{ Config Config }159 type ConfigChanged struct{ Config Config }
@@ -142,23 +161,69 @@ type ConfigChanged struct{ Config Config }
142 // ClientCommand carries core → wayland commands.161 // ClientCommand carries core → wayland commands.
143 type ClientCommand interface{ clientCommand() }162 type ClientCommand interface{ clientCommand() }
144 163
145-// ConfigureWindow carries core → wayland geometry and state.164+// ConfigureWindow carries core → wayland geometry and state. Output is the
165+// connector showing the window, empty while it is hidden.
146 type ConfigureWindow struct {166 type ConfigureWindow struct {
147 ID WindowID167 ID WindowID
148 Width, Height int168 Width, Height int
149 Fullscreen, Activated bool169 Fullscreen, Activated bool
170+ Output string
150 }171 }
151 172
152 func (ConfigureWindow) clientCommand() {}173 func (ConfigureWindow) clientCommand() {}
153 174
154-// SetOutputScale carries core → wayland the output scale and its logical size.175+// OutputPlacement is one output in the global layout. X, Y, Width and
155-// Window and pointer coordinates in other commands are logical.176+// Height are logical; physical = logical × Scale.
156-type SetOutputScale struct {177+type OutputPlacement struct {
157- Scale float64178+ Info OutputInfo
158- Width, Height int179+ X, Y, Width, Height int
180+ Scale float64
181+}
182+
183+// Contains reports whether the logical point is on the output.
184+func (o OutputPlacement) Contains(x, y float64) bool {
185+ return x >= float64(o.X) && x < float64(o.X+o.Width) && y >= float64(o.Y) && y < float64(o.Y+o.Height)
186+}
187+
188+// Layout is the global arrangement of outputs, left to right.
189+type Layout []OutputPlacement
190+
191+// At returns the output under the logical point.
192+func (l Layout) At(x, y float64) (OutputPlacement, bool) {
193+ for _, o := range l {
194+ if o.Contains(x, y) {
195+ return o, true
196+ }
197+ }
198+ return OutputPlacement{}, false
199+}
200+
201+// Clamp keeps the pointer on an output: a point outside every output stays
202+// on the output under (fromX, fromY), or the first one, clamped to its edges.
203+func (l Layout) Clamp(fromX, fromY, x, y float64) (float64, float64) {
204+ if len(l) == 0 {
205+ return x, y
206+ }
207+ if _, ok := l.At(x, y); ok {
208+ return x, y
209+ }
210+ o, ok := l.At(fromX, fromY)
211+ if !ok {
212+ o = l[0]
213+ }
214+ x = min(max(x, float64(o.X)), float64(o.X+o.Width)-1)
215+ y = min(max(y, float64(o.Y)), float64(o.Y+o.Height)-1)
216+ return x, y
217+}
218+
219+// SetOutputs carries core → wayland every output and the focused one (where
220+// new surfaces start). It is sent when the list, a placement or the focus changes.
221+type SetOutputs struct {
222+ Outputs Layout
223+ Focused string
159 }224 }
160 225
161-func (SetOutputScale) clientCommand() {}226+func (SetOutputs) clientCommand() {}
162 227
163 // SlotsPending tells wayland whether a slot waits for its window. Only then228 // SlotsPending tells wayland whether a slot waits for its window. Only then
164 // does it read the SlotEnv of mapping clients.229 // does it read the SlotEnv of mapping clients.
@@ -232,9 +297,11 @@ type SpawnRequest struct {
232 Env []string297 Env []string
233 }298 }
234 299
235-// Scene carries core → renderer immutable snapshots with fresh Windows slices.300+// Scene carries core → renderer immutable snapshots with fresh Windows slices,
236-// Rects and the output size are logical; the renderer multiplies by Scale.301+// one per output. Rects and the output size are logical and local to the
302+// output; the renderer multiplies by Scale.
237 type Scene struct {303 type Scene struct {
304+ Output string
238 Seq uint64305 Seq uint64
239 OutputWidth, OutputHeight int306 OutputWidth, OutputHeight int
240 Scale float64307 Scale float64

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 23

Tags 10

↑↓ moveenter openesc close