merge: multi-output (multiple monitors, hotplug, per-output layers)
46 files · +2666 -795
M cmd/nefertty/main.go +14 -7
| @@ -86,7 +86,7 @@ func run() error { | |||
| 86 | 86 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 87 | 87 | backend := flags.String("backend", "drm", "drm or headless") | |
| 88 | 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 | 90 | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 91 | 91 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 92 | 92 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| @@ -108,11 +108,18 @@ func run() error { | |||
| 108 | 108 | fmt.Fprintln(os.Stderr, err) | |
| 109 | 109 | return err | |
| 110 | 110 | } | |
| 111 | - | outW, outH, _, sizeErr := config.ParseMode(*size) | |
| 112 | - | if sizeErr != nil { | |
| 113 | - | err := usageError{fmt.Errorf("--size: %w", sizeErr)} | |
| 114 | - | fmt.Fprintln(os.Stderr, err) | |
| 115 | - | return err | |
| 111 | + | var sizes [][2]int | |
| 112 | + | for _, s := range strings.Split(*size, ",") { | |
| 113 | + | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | |
| 114 | + | if sizeErr != nil || w == 0 { | |
| 115 | + | 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 | 124 | if *inputPath != "" && *backend != "headless" { | |
| 118 | 125 | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |
| @@ -190,7 +197,7 @@ func run() error { | |||
| 190 | 197 | for _, w := range warnings { | |
| 191 | 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 | 201 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 195 | 202 | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 196 | 203 | err = nil | |
M internal/adapters/config/config.go +9 -2
| @@ -36,8 +36,12 @@ var defaultBinds = []struct{ combo, action string }{ | |||
| 36 | 36 | {"cmd+pagedown", "focus-workspace-down"}, | |
| 37 | 37 | {"cmd+shift+pageup", "move-to-workspace-up"}, | |
| 38 | 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 | 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 | 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 | 486 | w.Hidden = b | |
| 483 | 487 | return err | |
| 484 | 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 | 493 | case "max-columns": | |
| 487 | 494 | return positive(&w.MaxColumns, v, 16) | |
| 488 | 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 | 22 | func TestDefaultsAndLoad(t *testing.T) { | |
| 23 | 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 | 25 | t.Fatalf("defaults: %+v", d) | |
| 26 | 26 | } | |
| 27 | 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 | 249 | workspace.lost.hidden = on | |
| 250 | 250 | `) | |
| 251 | 251 | want := []ports.WorkspaceConfig{ | |
| 252 | - | {Name: "dev", Hidden: true, MaxColumns: 3}, | |
| 252 | + | {Name: "dev", Monitor: "DP-2", Hidden: true, MaxColumns: 3}, | |
| 253 | 253 | {Name: "web", Overflow: "scroll"}, | |
| 254 | 254 | {Name: "lost", Hidden: true}, | |
| 255 | 255 | } | |
| @@ -263,7 +263,7 @@ workspace.lost.hidden = on | |||
| 263 | 263 | for _, x := range w { | |
| 264 | 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 | 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 | 74 | size int // buffer side | |
| 75 | 75 | hotX, hotY int | |
| 76 | 76 | shown bool | |
| 77 | + | hidden bool // Hide was called; the next move shows it again | |
| 77 | 78 | plane cursorPlane | |
| 78 | 79 | ||
| 79 | 80 | mu sync.Mutex // guards x, y and stats; never held across an ioctl | |
| @@ -147,7 +148,11 @@ func (c *Cursor) start() { | |||
| 147 | 148 | // fails too and Reapply restores the cursor. | |
| 148 | 149 | _ = c.plane.WaitVblank() | |
| 149 | 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 | 156 | x, y := c.position() | |
| 152 | 157 | v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)} | |
| 153 | 158 | // 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 | 201 | // apply (re)attaches the buffer at the current position. Callers hold c.io. | |
| 197 | 202 | func (c *Cursor) apply() error { | |
| 198 | - | if !c.shown || c.buf == nil { | |
| 203 | + | if !c.shown || c.hidden || c.buf == nil { | |
| 199 | 204 | return nil | |
| 200 | 205 | } | |
| 201 | 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 | 237 | // Reapply shows the cursor again after a modeset (VT switch back). | |
| 220 | 238 | func (c *Cursor) Reapply() error { | |
| 221 | 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 | 2 | package drm | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "errors" | |
| 5 | + | "encoding/binary" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "math" | |
| 8 | 8 | "unsafe" | |
| @@ -114,14 +114,16 @@ type connector struct { | |||
| 114 | 114 | ||
| 115 | 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 | 118 | type Want struct { | |
| 119 | - | Name string | |
| 120 | - | W, H int | |
| 121 | - | Hz float64 // 0: highest refresh at W x H | |
| 122 | 119 | Disabled map[string]bool | |
| 123 | - | // Strict accepts only the connector named Name (used to search every card first). | |
| 124 | - | Strict bool | |
| 120 | + | // Modes maps a connector to W, H and Hz (0: highest refresh at W x H). | |
| 121 | + | 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 | 129 | // refreshMilli is the exact refresh in mHz, computed from the timings like wlroots. | |
| @@ -142,37 +144,20 @@ func (m modeInfo) refreshMilli() int { | |||
| 142 | 144 | return int(r) | |
| 143 | 145 | } | |
| 144 | 146 | ||
| 145 | - | // pickConnector applies want: the named connector if connected, else the first | |
| 146 | - | // connected one that is not disabled. The mode is the requested one, else preferred. | |
| 147 | - | func pickConnector(conns []connector, want Want) (connector, modeInfo, error) { | |
| 148 | - | var chosen *connector | |
| 149 | - | for i := range conns { | |
| 150 | - | c := &conns[i] | |
| 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 | |
| 147 | + | // pickMode returns the configured mode of the connector, else its preferred | |
| 148 | + | // one, else the first. | |
| 149 | + | func (w Want) pickMode(c connector) modeInfo { | |
| 150 | + | if m, ok := w.Modes[c.name]; ok { | |
| 151 | + | if mode, ok := pickMode(c.modes, int(m[0]), int(m[1]), m[2]); ok { | |
| 152 | + | return mode | |
| 168 | 153 | } | |
| 169 | 154 | } | |
| 170 | - | for _, m := range chosen.modes { | |
| 155 | + | for _, m := range c.modes { | |
| 171 | 156 | if m.Type&modeTypePrefered != 0 { | |
| 172 | - | return *chosen, m, nil | |
| 157 | + | return m | |
| 173 | 158 | } | |
| 174 | 159 | } | |
| 175 | - | return *chosen, chosen.modes[0], nil | |
| 160 | + | return c.modes[0] | |
| 176 | 161 | } | |
| 177 | 162 | ||
| 178 | 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 | 230 | }, nil | |
| 246 | 231 | } | |
| 247 | 232 | ||
| 248 | - | // pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows. | |
| 249 | - | func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) { | |
| 233 | + | // pickCrtc prefers the connector's current encoder CRTC, else any free CRTC | |
| 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 | 236 | encs := c.encoders | |
| 251 | 237 | if c.encoderID != 0 { | |
| 252 | 238 | encs = append([]uint32{c.encoderID}, encs...) | |
| @@ -256,16 +242,16 @@ func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) { | |||
| 256 | 242 | if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil { | |
| 257 | 243 | continue | |
| 258 | 244 | } | |
| 259 | - | if id == c.encoderID && e.crtcID != 0 { | |
| 245 | + | if id == c.encoderID && e.crtcID != 0 && !used[e.crtcID] { | |
| 260 | 246 | return e.crtcID, nil | |
| 261 | 247 | } | |
| 262 | 248 | for i, crtc := range crtcs { | |
| 263 | - | if e.possibleCrtcs&(1<<i) != 0 { | |
| 249 | + | if e.possibleCrtcs&(1<<i) != 0 && !used[crtc] { | |
| 264 | 250 | 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 | 257 | type dumbBuffer struct { | |
| @@ -333,19 +319,21 @@ func flip(fd int, crtc, fb uint32) error { | |||
| 333 | 319 | return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p)) | |
| 334 | 320 | } | |
| 335 | 321 | ||
| 336 | - | // countFlipEvents parses DRM events read from the card fd. | |
| 337 | - | func countFlipEvents(buf []byte) int { | |
| 338 | - | n := 0 | |
| 322 | + | // flipCrtcs parses DRM events read from the card fd and returns the CRTC of | |
| 323 | + | // each completed page flip. | |
| 324 | + | func flipCrtcs(buf []byte) []uint32 { | |
| 325 | + | var out []uint32 | |
| 339 | 326 | for len(buf) >= 8 { | |
| 340 | - | typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24 | |
| 341 | - | length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24) | |
| 327 | + | typ := binary.LittleEndian.Uint32(buf) | |
| 328 | + | length := int(binary.LittleEndian.Uint32(buf[4:])) | |
| 342 | 329 | if length < 8 || length > len(buf) { | |
| 343 | 330 | break | |
| 344 | 331 | } | |
| 345 | - | if typ == eventFlipDone { | |
| 346 | - | n++ | |
| 332 | + | // struct drm_event_vblank: base(8) user_data(8) sec usec sequence crtc_id. | |
| 333 | + | if typ == eventFlipDone && length >= 32 { | |
| 334 | + | out = append(out, binary.LittleEndian.Uint32(buf[28:])) | |
| 347 | 335 | } | |
| 348 | 336 | buf = buf[length:] | |
| 349 | 337 | } | |
| 350 | - | return n | |
| 338 | + | 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 | 34 | mode := func(w, h uint16, hz uint32, pref bool) modeInfo { | |
| 35 | 35 | // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h). | |
| 36 | 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 | 40 | return m | |
| 41 | 41 | } | |
| 42 | 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 | 43 | off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}} | |
| 45 | 44 | for _, tc := range []struct { | |
| 46 | - | name string | |
| 47 | - | conns []connector | |
| 48 | - | want Want | |
| 49 | - | conn string | |
| 50 | - | mode string | |
| 51 | - | err bool | |
| 45 | + | name string | |
| 46 | + | want Want | |
| 47 | + | mode string | |
| 52 | 48 | }{ | |
| 53 | - | {"none", nil, Want{}, "", "", true}, | |
| 54 | - | {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false}, | |
| 55 | - | {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false}, | |
| 56 | - | {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false}, | |
| 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}, | |
| 49 | + | {"first preferred", Want{}, "5120x2160@165.000"}, | |
| 50 | + | {"closest refresh", Want{Modes: map[string][3]float64{"DP-2": {5120, 2160, 99}}}, "5120x2160@100.000"}, | |
| 51 | + | {"highest refresh", Want{Modes: map[string][3]float64{"DP-2": {5120, 2160, 0}}}, "5120x2160@165.000"}, | |
| 52 | + | {"unknown mode falls back to preferred", Want{Modes: map[string][3]float64{"DP-2": {800, 600, 0}}}, "5120x2160@165.000"}, | |
| 64 | 53 | } { | |
| 65 | - | c, m, err := pickConnector(tc.conns, tc.want) | |
| 66 | - | if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) { | |
| 67 | - | t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err) | |
| 54 | + | if m := tc.want.pickMode(lg); m.String() != tc.mode { | |
| 55 | + | t.Errorf("%s: got %s", tc.name, m) | |
| 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 | 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) { | |
| 95 | - | ev := func(typ uint32, n int) []byte { | |
| 96 | - | b := make([]byte, n) | |
| 85 | + | func TestFlipCrtcs(t *testing.T) { | |
| 86 | + | ev := func(typ, crtc uint32) []byte { | |
| 87 | + | b := make([]byte, 32) | |
| 97 | 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 | 91 | return b | |
| 100 | 92 | } | |
| 101 | - | buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...) | |
| 102 | - | if n := countFlipEvents(buf); n != 2 { | |
| 103 | - | t.Fatalf("got %d flips", n) | |
| 93 | + | buf := append(append(ev(eventFlipDone, 41), ev(1, 7)...), ev(eventFlipDone, 42)...) | |
| 94 | + | if got := flipCrtcs(buf); len(got) != 2 || got[0] != 41 || got[1] != 42 { | |
| 95 | + | t.Fatalf("got %v", got) | |
| 104 | 96 | } | |
| 105 | - | if n := countFlipEvents(buf[:20]); n != 0 { | |
| 106 | - | t.Fatalf("truncated: got %d", n) | |
| 97 | + | if got := flipCrtcs(buf[:20]); len(got) != 0 { | |
| 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 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "slices" | |
| 8 | - | "sync" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -13,9 +12,11 @@ import ( | |||
| 13 | 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 | 17 | type Output struct { | |
| 18 | 18 | fd int | |
| 19 | + | flipped <-chan int // page flips of this CRTC, from Card.ReadEvents | |
| 19 | 20 | crtc uint32 | |
| 20 | 21 | conn connector | |
| 21 | 22 | mode modeInfo | |
| @@ -34,74 +35,39 @@ type Output struct { | |||
| 34 | 35 | // pixels on a side. | |
| 35 | 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 | - | type Info struct { | |
| 39 | - | Name string | |
| 40 | - | Monitor Monitor | |
| 41 | - | Width, Height int | |
| 42 | - | RefreshMilli int | |
| 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 { | |
| 38 | + | // newOutput allocates buffers and the cursor for a connector on crtc. | |
| 39 | + | func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) { | |
| 40 | + | log := card.log | |
| 41 | + | o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name)} | |
| 42 | + | var err error | |
| 43 | + | if o.saved, err = getCrtc(card.fd, crtc); err != nil { | |
| 84 | 44 | log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit") | |
| 85 | 45 | } | |
| 86 | 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 | 48 | o.Close() | |
| 89 | 49 | return nil, err | |
| 90 | 50 | } | |
| 91 | 51 | } | |
| 92 | - | if o.cursor, err = newCursor(fd, crtc, slices.Index(crtcs, crtc)); err != nil { | |
| 93 | - | log.Warn().Err(err).Msg("no hardware cursor") | |
| 52 | + | if o.cursor, err = newCursor(card.fd, crtc, slices.Index(card.crtcs, crtc)); err != nil { | |
| 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 | 56 | return o, nil | |
| 97 | 57 | } | |
| 98 | 58 | ||
| 99 | 59 | // Cursor is the hardware cursor, or nil when the driver has none. | |
| 100 | 60 | func (o *Output) Cursor() *Cursor { return o.cursor } | |
| 101 | 61 | ||
| 102 | - | // Info returns the chosen connector, monitor and mode. | |
| 103 | - | func (o *Output) Info() Info { | |
| 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} | |
| 62 | + | // Info describes the output for core and clients. Unknown EDID fields are empty. | |
| 63 | + | func (o *Output) Info() ports.OutputInfo { | |
| 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 | 73 | func (o *Output) Width() int { return int(o.mode.HDisplay) } | |
| @@ -130,7 +96,8 @@ func (o *Output) present(r ports.Renderer) error { | |||
| 130 | 96 | 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 | 101 | func (o *Output) Close() { | |
| 135 | 102 | if o.cursor != nil { | |
| 136 | 103 | o.cursor.close() | |
| @@ -156,19 +123,21 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 156 | 123 | return fmt.Errorf("create renderer: %w", err) | |
| 157 | 124 | } | |
| 158 | 125 | defer r.Close() | |
| 159 | - | if err := o.modeset(); err != nil { | |
| 160 | - | return err | |
| 126 | + | // The seat sends its state first; while switched away (e.g. a monitor | |
| 127 | + | // 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 | 138 | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 170 | 139 | var scene ports.Scene | |
| 171 | - | haveScene, dirty, enabled := false, false, true | |
| 140 | + | haveScene, dirty := false, false | |
| 172 | 141 | cursorScale := -1.0 // not loaded yet | |
| 173 | 142 | frame := 0 | |
| 174 | 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 | 146 | select { | |
| 178 | 147 | case <-ctx.Done(): | |
| 179 | 148 | return nil | |
| 180 | - | case err := <-evErr: | |
| 181 | - | return err | |
| 182 | 149 | case on := <-active: | |
| 183 | 150 | // Modeset on every enable: a fast disable+enable can coalesce to one true. | |
| 184 | 151 | if on { | |
| @@ -195,7 +162,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 195 | 162 | } | |
| 196 | 163 | enabled = on | |
| 197 | 164 | o.log.Info().Bool("enabled", on).Msg("output") | |
| 198 | - | case n := <-events: | |
| 165 | + | case n := <-o.flipped: | |
| 199 | 166 | if !o.pending { | |
| 200 | 167 | continue // stale event from before a modeset | |
| 201 | 168 | } | |
| @@ -266,33 +233,3 @@ func (o *Output) setCursor(load CursorLoader, scale float64) { | |||
| 266 | 233 | } | |
| 267 | 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 | 10 | // Cursor is the software cursor drawn into screenshots; it plays the role of | |
| 11 | 11 | // the DRM cursor plane. Move is called from the input goroutine. | |
| 12 | 12 | type Cursor struct { | |
| 13 | - | mu sync.Mutex | |
| 14 | - | x, y float64 | |
| 15 | - | img ports.CursorImage | |
| 13 | + | mu sync.Mutex | |
| 14 | + | x, y float64 | |
| 15 | + | img ports.CursorImage | |
| 16 | + | hidden bool | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | func (c *Cursor) Move(x, y float64) { | |
| 19 | 20 | c.mu.Lock() | |
| 20 | - | c.x, c.y = x, y | |
| 21 | + | 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 | 29 | c.mu.Unlock() | |
| 22 | 30 | } | |
| 23 | 31 | ||
| @@ -31,6 +39,9 @@ func (c *Cursor) set(img ports.CursorImage) { | |||
| 31 | 39 | func (c *Cursor) draw(dst *image.RGBA) { | |
| 32 | 40 | c.mu.Lock() | |
| 33 | 41 | defer c.mu.Unlock() | |
| 42 | + | if c.hidden { | |
| 43 | + | return | |
| 44 | + | } | |
| 34 | 45 | img := c.img | |
| 35 | 46 | ox, oy := int(c.x)-img.HotX, int(c.y)-img.HotY | |
| 36 | 47 | b := dst.Bounds() | |
M internal/adapters/headlessinput/input.go +9 -5
| @@ -15,9 +15,12 @@ import ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // Run owns the keymap and converts script lines into input events. A keymap | |
| 18 | - | // received on keymaps replaces the current one, which is closed. | |
| 19 | - | // moveCursor, when set, places the software cursor on every scripted move. | |
| 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 { | |
| 18 | + | // received on keymaps replaces the current one, which is closed. Scripted | |
| 19 | + | // moves are global logical coordinates; moveCursor, when set, places the | |
| 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 | 24 | defer func() { km.Close() }() | |
| 22 | 25 | emit := func(code uint32, down bool) error { | |
| 23 | 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 | 82 | km.Close() | |
| 80 | 83 | km = next | |
| 81 | 84 | log.Info().Msg("keymap replaced") | |
| 85 | + | case layout = <-layouts: | |
| 82 | 86 | case line, ok := <-script: | |
| 83 | 87 | if !ok { | |
| 84 | 88 | return nil | |
| @@ -109,8 +113,8 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 109 | 113 | x, ex := strconv.ParseFloat(fields[1], 64) | |
| 110 | 114 | y, ey := strconv.ParseFloat(fields[2], 64) | |
| 111 | 115 | if ex == nil && ey == nil && !math.IsNaN(x) && !math.IsNaN(y) && !math.IsInf(x, 0) && !math.IsInf(y, 0) { | |
| 112 | - | if moveCursor != nil { | |
| 113 | - | moveCursor(x, y) | |
| 116 | + | if o, ok := layout.At(x, y); ok && moveCursor != nil { | |
| 117 | + | moveCursor(o.Info.Name, (x-float64(o.X))*o.Scale, (y-float64(o.Y))*o.Scale) | |
| 114 | 118 | } | |
| 115 | 119 | if err := sendPointer(ports.PointerMotion{X: x, Y: y, TimeMsec: now}); err != nil { | |
| 116 | 120 | return err | |
M internal/adapters/headlessinput/input_test.go +2 -2
| @@ -31,7 +31,7 @@ func TestScript(t *testing.T) { | |||
| 31 | 31 | script <- tt.line | |
| 32 | 32 | close(script) | |
| 33 | 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 | 35 | t.Fatal(err) | |
| 36 | 36 | } | |
| 37 | 37 | if len(input) != len(tt.names) { | |
| @@ -67,7 +67,7 @@ func TestPointerScript(t *testing.T) { | |||
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| 69 | 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 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | 73 | if len(input) != 5 { | |
M internal/adapters/libinput/libinput.go +71 -23
| @@ -132,12 +132,15 @@ type Options struct { | |||
| 132 | 132 | SeatName string | |
| 133 | 133 | Keymap *xkb.Keymap | |
| 134 | 134 | // Keymaps replaces Keymap live; Run takes ownership and closes the old one. | |
| 135 | - | Keymaps <-chan *xkb.Keymap | |
| 136 | - | Width, Height int | |
| 137 | - | Active <-chan bool | |
| 138 | - | // MoveCursor, when set, places the hardware cursor at the new physical | |
| 139 | - | // position as soon as motion is read, before core sees the event. | |
| 140 | - | MoveCursor func(x, y float64) | |
| 135 | + | Keymaps <-chan *xkb.Keymap | |
| 136 | + | // Layout is the initial output layout; Layouts replaces it live. | |
| 137 | + | Layout ports.Layout | |
| 138 | + | Layouts <-chan ports.Layout | |
| 139 | + | Active <-chan bool | |
| 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 | 144 | Log zerowrap.Logger | |
| 142 | 145 | } | |
| 143 | 146 | ||
| @@ -168,11 +171,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 168 | 171 | if assignSeat(li, name) != 0 { | |
| 169 | 172 | return fmt.Errorf("libinput_udev_assign_seat %s failed", name) | |
| 170 | 173 | } | |
| 171 | - | p := pointer{w: float64(opts.Width), h: float64(opts.Height)} | |
| 172 | - | p.x, p.y = p.w/2, p.h/2 | |
| 173 | - | if opts.MoveCursor != nil { | |
| 174 | - | opts.MoveCursor(p.x, p.y) | |
| 175 | - | } | |
| 174 | + | p := newPointer(opts.Layout) | |
| 175 | + | p.moved(opts.MoveCursor) | |
| 176 | 176 | fd := getFD(li) | |
| 177 | 177 | fwd := newForwarder() | |
| 178 | 178 | fwdCtx, stopFwd := context.WithCancel(ctx) | |
| @@ -199,6 +199,9 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 199 | 199 | opts.Keymap.Close() | |
| 200 | 200 | opts.Keymap = km | |
| 201 | 201 | opts.Log.Info().Msg("keymap replaced") | |
| 202 | + | case l := <-opts.Layouts: | |
| 203 | + | p.setLayout(l) | |
| 204 | + | p.moved(opts.MoveCursor) | |
| 202 | 205 | default: | |
| 203 | 206 | } | |
| 204 | 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 | 216 | opts.Log.Warn().Msg("libinput_dispatch failed") | |
| 214 | 217 | } | |
| 215 | 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 | 220 | eventDestroy(ev) | |
| 218 | 221 | if err != nil { | |
| 219 | 222 | return err | |
| @@ -251,17 +254,15 @@ func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |||
| 251 | 254 | case evPointerMotion: | |
| 252 | 255 | pe := pointerEvent(ev) | |
| 253 | 256 | x, y := p.move(pointerDX(pe), pointerDY(pe)) | |
| 254 | - | if opts.MoveCursor != nil { | |
| 255 | - | opts.MoveCursor(x, y) | |
| 256 | - | } | |
| 257 | + | p.moved(opts.MoveCursor) | |
| 257 | 258 | log.Debug().Float64("x", x).Float64("y", y).Msg("pointer") | |
| 258 | 259 | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 259 | 260 | case evPointerAbs: | |
| 261 | + | // Absolute devices (tablets, VMs) map to the whole layout. | |
| 260 | 262 | pe := pointerEvent(ev) | |
| 261 | - | x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h))) | |
| 262 | - | if opts.MoveCursor != nil { | |
| 263 | - | opts.MoveCursor(x, y) | |
| 264 | - | } | |
| 263 | + | b := p.bounds() | |
| 264 | + | x, y := p.set(float64(b.X)+pointerAbsX(pe, uint32(b.W)), float64(b.Y)+pointerAbsY(pe, uint32(b.H))) | |
| 265 | + | p.moved(opts.MoveCursor) | |
| 265 | 266 | return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil | |
| 266 | 267 | case evPointerButton: | |
| 267 | 268 | pe := pointerEvent(ev) | |
| @@ -305,12 +306,59 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |||
| 305 | 306 | 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 | 344 | func (p *pointer) set(x, y float64) (float64, float64) { | |
| 313 | - | p.x = min(max(x, 0), p.w-1) | |
| 314 | - | p.y = min(max(y, 0), p.h-1) | |
| 345 | + | p.x, p.y = p.layout.Clamp(p.x, p.y, x, y) | |
| 315 | 346 | 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) { | |
| 33 | - | p := pointer{x: 10, y: 10, w: 100, h: 50} | |
| 34 | - | if x, y := p.move(-20, 100); x != 0 || y != 49 { | |
| 32 | + | func TestPointerAcrossOutputs(t *testing.T) { | |
| 33 | + | layout := ports.Layout{ | |
| 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 | 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 | 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 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "runtime" | |
| 9 | + | "slices" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "unsafe" | |
| 11 | 12 | ||
| @@ -138,6 +139,13 @@ func (s *Seat) Subscribe() <-chan bool { | |||
| 138 | 139 | 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 | 149 | func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error { | |
| 142 | 150 | if ctx.Err() != nil { | |
| 143 | 151 | return ctx.Err() | |
M internal/adapters/wayland/commands_test.go +1 -1
| @@ -41,7 +41,7 @@ func TestDrainCommandsCollapsesMotion(t *testing.T) { | |||
| 41 | 41 | // must be absorbed without core blocking. | |
| 42 | 42 | func TestCommandThroughput(t *testing.T) { | |
| 43 | 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 | 45 | if err != nil { | |
| 46 | 46 | t.Fatal(err) | |
| 47 | 47 | } | |
M internal/adapters/wayland/globals.go +3 -31
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | func registerGlobals(d *server.Display, o Options, s *Server) error { | |
| 12 | 12 | for _, register := range []func() error{ | |
| 13 | 13 | func() error { return registerXDG(d, s) }, | |
| 14 | - | func() error { return registerLayer(d, o, s) }, | |
| 15 | - | func() error { return registerXDGOutput(d, o, s) }, | |
| 14 | + | func() error { return registerLayer(d, s) }, | |
| 15 | + | func() error { return registerXDGOutput(d, s) }, | |
| 16 | 16 | func() error { return registerDecoration(d, s) }, | |
| 17 | 17 | func() error { return registerScale(d, s) }, | |
| 18 | 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 | 35 | func() error { | |
| 60 | 36 | return wayland.NewSeatGlobal(d, 7, func(c server.Client, v, id uint32) { | |
| 61 | 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 | 65 | if w, err := wayland.NewSurface(r.Client(), r.Version(), id, state); err == nil { | |
| 90 | 66 | state.wl = w | |
| 91 | 67 | c.server.surfaces[w.Resource] = state | |
| 92 | - | c.server.sendSurfaceScale(w) | |
| 68 | + | state.sendScale() | |
| 93 | 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 | 223 | 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 | 226 | type seat struct{ server *Server } | |
| 255 | 227 | ||
| 256 | 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 | 32 | dir := t.TempDir() | |
| 33 | 33 | events := make(chan ports.ClientEvent, 16) | |
| 34 | 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 | 36 | if err != nil { | |
| 37 | 37 | t.Fatal(err) | |
| 38 | 38 | } | |
M internal/adapters/wayland/layer.go +39 -5
| @@ -26,15 +26,19 @@ type layerSurface struct { | |||
| 26 | 26 | pending, current layerState | |
| 27 | 27 | configured, acked, mapped bool | |
| 28 | 28 | 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 | 36 | return wlrlayershell.NewZwlrLayerShellV1Global(d, 4, func(c server.Client, v, id uint32) { | |
| 33 | 37 | _, _ = wlrlayershell.NewZwlrLayerShellV1(c, int32(v), id, layerShell{s}) | |
| 34 | 38 | }) | |
| 35 | 39 | } | |
| 36 | 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 | 42 | if w == nil || h.server.surfaces[w.Resource] == nil { | |
| 39 | 43 | return | |
| 40 | 44 | } | |
| @@ -47,7 +51,12 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32 | |||
| 47 | 51 | r.PostError(uint32(wlrlayershell.ZwlrLayerShellV1ErrorInvalidLayer), "invalid layer") | |
| 48 | 52 | 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 | 60 | l.pending.layer = ports.Layer(layer) | |
| 52 | 61 | resource, err := wlrlayershell.NewZwlrLayerSurfaceV1(r.Client(), r.Version(), id, l) | |
| 53 | 62 | if err != nil { | |
| @@ -59,6 +68,9 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32 | |||
| 59 | 68 | state.layer = l | |
| 60 | 69 | state.role = l.commit | |
| 61 | 70 | h.server.layers[l.id] = l | |
| 71 | + | if gone { | |
| 72 | + | l.close() | |
| 73 | + | } | |
| 62 | 74 | resource.OnDestroy = func() { | |
| 63 | 75 | l.unmap() | |
| 64 | 76 | delete(h.server.layers, l.id) | |
| @@ -130,6 +142,18 @@ func (l *layerSurface) unmap() { | |||
| 130 | 142 | l.shell.server.layerChanged() | |
| 131 | 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 | 157 | func (s *Server) layerChanged() { | |
| 134 | 158 | list := make([]ports.LayerSurface, 0) | |
| 135 | 159 | for _, l := range s.layers { | |
| @@ -137,6 +161,9 @@ func (s *Server) layerChanged() { | |||
| 137 | 161 | continue | |
| 138 | 162 | } | |
| 139 | 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 | 167 | if l.surface.current != nil { | |
| 141 | 168 | if b := s.buffers[l.surface.current.Resource]; b != nil { | |
| 142 | 169 | v.Width, v.Height = l.surface.logicalSize(b.width, b.height) | |
| @@ -152,11 +179,15 @@ func (s *Server) layerChanged() { | |||
| 152 | 179 | // the logical output size. | |
| 153 | 180 | func (l *layerSurface) sendConfigure() { | |
| 154 | 181 | 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 | 186 | if w == 0 { | |
| 156 | - | w = uint32(l.shell.server.scale.width) | |
| 187 | + | w = uint32(ow) | |
| 157 | 188 | } | |
| 158 | 189 | if h == 0 { | |
| 159 | - | h = uint32(l.shell.server.scale.height) | |
| 190 | + | h = uint32(oh) | |
| 160 | 191 | } | |
| 161 | 192 | l.shell.server.serial++ | |
| 162 | 193 | l.resource.SendConfigure(l.shell.server.serial, w, h) | |
| @@ -170,6 +201,9 @@ func (l *layerSurface) commit(buffer bool) { | |||
| 170 | 201 | delete(l.shell.server.layers, l.id) | |
| 171 | 202 | return | |
| 172 | 203 | } | |
| 204 | + | if l.closed { | |
| 205 | + | return | |
| 206 | + | } | |
| 173 | 207 | p := l.pending | |
| 174 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/nefertty/internal/ports" | |
| 7 | 6 | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| 8 | 7 | "github.com/bnema/purego-libwayland/protocol/viewporter" | |
| 9 | 8 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 10 | - | "github.com/bnema/purego-libwayland/protocol/xdgoutput" | |
| 11 | 9 | "github.com/bnema/purego-libwayland/server" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| 14 | - | // Output scale (HiDPI). Clients that bind wp_fractional_scale_v1 get the exact | |
| 15 | - | // scale in 1/120 steps and draw at physical size through wp_viewporter. Others | |
| 16 | - | // get wl_output.scale rounded up, draw at that integer, and the renderer | |
| 17 | - | // downscales. Every size sent to clients (configures, xdg-output, layer | |
| 18 | - | // configures, pointer coordinates) stays logical. | |
| 19 | - | ||
| 20 | - | // scaleState is what clients were last told about the output. | |
| 21 | - | type scaleState struct { | |
| 22 | - | scale float64 | |
| 23 | - | width, height int // logical | |
| 24 | - | outputs []*wayland.Output | |
| 25 | - | xdgOutputs []*xdgoutput.ZxdgOutputV1 | |
| 26 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 12 | + | // Surfaces follow the output of their window or layer (the focused output | |
| 13 | + | // until they are shown): wl_surface.enter/leave, preferred buffer scale and | |
| 14 | + | // fractional scale all come from it. | |
| 15 | + | ||
| 16 | + | // outputOfSurface is the output a surface is on: its window's configured | |
| 17 | + | // output, its layer output, or the focused one. | |
| 18 | + | func (s *Server) outputOfSurface(surf *surface) *output { | |
| 19 | + | name := "" | |
| 20 | + | switch { | |
| 21 | + | case surf.xdg != nil && surf.xdg.window != nil: | |
| 22 | + | name = surf.xdg.window.last.Output | |
| 23 | + | case surf.layer != nil && surf.layer.output != nil: | |
| 24 | + | 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. | |
| 30 | - | func (st *scaleState) integerScale() int32 { return int32(math.Ceil(st.scale - 1e-9)) } | |
| 31 | - | ||
| 32 | - | func (s *Server) setOutputScale(c ports.SetOutputScale) { | |
| 33 | - | st := &s.scale | |
| 34 | - | scaleChanged := c.Scale != st.scale | |
| 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 | - | } | |
| 29 | + | // sendScale tells the surface about its output: enter/leave, the integer | |
| 30 | + | // buffer scale (wl_surface v6+) and the fractional scale. | |
| 31 | + | func (surf *surface) sendScale() { | |
| 32 | + | s := surf.server | |
| 33 | + | if surf.destroyed || surf.wl == nil || !surf.wl.Resource.Alive() { | |
| 34 | + | return | |
| 48 | 35 | } | |
| 49 | - | if scaleChanged { | |
| 50 | - | for _, surf := range s.surfaces { | |
| 51 | - | s.sendSurfaceScale(surf.wl) | |
| 36 | + | o := s.outputOfSurface(surf) | |
| 37 | + | if o != surf.on { | |
| 38 | + | if surf.on != nil { | |
| 39 | + | surf.leave(surf.on) | |
| 52 | 40 | } | |
| 53 | - | for surf, f := range st.fractions { | |
| 54 | - | if !surf.destroyed { | |
| 55 | - | f.SendPreferredScale(uint32(math.Round(st.scale * 120))) | |
| 41 | + | surf.on = o | |
| 42 | + | if o != nil { | |
| 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. | |
| 60 | - | for _, l := range s.layers { | |
| 61 | - | // Uses committed state only: pending requests wait for the client's commit. | |
| 62 | - | if l.configured && (l.current.width == 0 || l.current.height == 0) { | |
| 63 | - | l.sendConfigure() | |
| 64 | - | } | |
| 50 | + | scale := 1.0 | |
| 51 | + | if o != nil { | |
| 52 | + | scale = o.place.Scale | |
| 53 | + | } | |
| 54 | + | if scale == surf.scale { | |
| 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. | |
| 69 | - | func (s *Server) sendSurfaceScale(w *wayland.Surface) { | |
| 70 | - | if w != nil && w.Version() >= 6 { | |
| 71 | - | w.SendPreferredBufferScale(s.scale.integerScale()) | |
| 66 | + | // leave sends wl_surface.leave for o when the surface is on it. | |
| 67 | + | func (surf *surface) leave(o *output) { | |
| 68 | + | if surf.on != o || o == nil { | |
| 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 | 99 | if surf == nil { | |
| 93 | 100 | return | |
| 94 | 101 | } | |
| 95 | - | if _, dup := s.scale.fractions[surf]; dup { | |
| 102 | + | if _, dup := s.fractions[surf]; dup { | |
| 96 | 103 | r.PostError(uint32(fractionalscale.WpFractionalScaleManagerV1ErrorFractionalScaleExists), "fractional scale already exists") | |
| 97 | 104 | return | |
| 98 | 105 | } | |
| @@ -100,9 +107,13 @@ func (m fractionManager) GetFractionalScale(r *fractionalscale.WpFractionalScale | |||
| 100 | 107 | if err != nil { | |
| 101 | 108 | return | |
| 102 | 109 | } | |
| 103 | - | s.scale.fractions[surf] = f | |
| 104 | - | f.OnDestroy = func() { delete(s.scale.fractions, surf) } | |
| 105 | - | f.SendPreferredScale(uint32(math.Round(s.scale.scale * 120))) | |
| 110 | + | s.fractions[surf] = f | |
| 111 | + | f.OnDestroy = func() { delete(s.fractions, surf) } | |
| 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 | 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 | 44 | events := make(chan ports.ClientEvent, 16) | |
| 45 | 45 | commands := make(chan ports.ClientCommand, 16) | |
| 46 | 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 | 48 | if err != nil { | |
| 49 | 49 | t.Fatal(err) | |
| 50 | 50 | } | |
| @@ -92,7 +92,7 @@ func TestFractionalScaleAndViewport(t *testing.T) { | |||
| 92 | 92 | t.Fatalf("initial scale %d", v) | |
| 93 | 93 | } | |
| 94 | 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 | 96 | // Events are read by Roundtrip; the command is applied asynchronously. | |
| 97 | 97 | var got uint32 | |
| 98 | 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 | 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 | 21 | type Options struct { | |
| 20 | - | RuntimeDir string | |
| 21 | - | OutputWidth, OutputHeight int | |
| 22 | - | Output OutputInfo | |
| 23 | - | Keymap string | |
| 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 | |
| 22 | + | RuntimeDir string | |
| 23 | + | Outputs ports.Layout | |
| 24 | + | Keymap string | |
| 25 | + | RepeatRate, RepeatDelay int | |
| 47 | 26 | } | |
| 48 | 27 | ||
| 49 | 28 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| @@ -93,7 +72,9 @@ type Server struct { | |||
| 93 | 72 | contentSeq map[ports.WindowID]uint64 | |
| 94 | 73 | contentNotify chan struct{} | |
| 95 | 74 | contentReady chan struct{} | |
| 96 | - | scale scaleState | |
| 75 | + | outputs []*output | |
| 76 | + | focusedOutput string | |
| 77 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 97 | 78 | } | |
| 98 | 79 | ||
| 99 | 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 | 109 | return nil, err | |
| 129 | 110 | } | |
| 130 | 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 | 113 | if opts.Keymap != "" { | |
| 133 | 114 | fd, size, e := keymapFile(opts.Keymap) | |
| 134 | 115 | if e != nil { | |
| @@ -149,6 +130,9 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 149 | 130 | d.Close() | |
| 150 | 131 | return nil, err | |
| 151 | 132 | } | |
| 133 | + | for _, p := range opts.Outputs { | |
| 134 | + | s.addOutput(p) | |
| 135 | + | } | |
| 152 | 136 | return s, nil | |
| 153 | 137 | } | |
| 154 | 138 | func (s *Server) SocketName() string { return s.name } | |
| @@ -420,8 +404,11 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 420 | 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 | 405 | w.last, w.hasLast = c, true | |
| 422 | 406 | w.sendConfigure() | |
| 423 | - | case ports.SetOutputScale: | |
| 424 | - | s.setOutputScale(c) | |
| 407 | + | if surf := w.xdg.surface; surf != nil && c.Output != "" { | |
| 408 | + | surf.sendScale() | |
| 409 | + | } | |
| 410 | + | case ports.SetOutputs: | |
| 411 | + | s.setOutputs(c) | |
| 425 | 412 | case ports.SlotsPending: | |
| 426 | 413 | s.slotsPending = c.Pending | |
| 427 | 414 | case ports.CloseWindow: | |
M internal/adapters/wayland/server_test.go +8 -5
| @@ -21,7 +21,7 @@ func TestWaylandInfo(t *testing.T) { | |||
| 21 | 21 | t.Skip("wayland-info not installed") | |
| 22 | 22 | } | |
| 23 | 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 | 25 | if err != nil { | |
| 26 | 26 | t.Fatal(err) | |
| 27 | 27 | } | |
| @@ -63,7 +63,7 @@ func TestWestonFrames(t *testing.T) { | |||
| 63 | 63 | t.Skip("weston-simple-shm not installed") | |
| 64 | 64 | } | |
| 65 | 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 | 67 | if err != nil { | |
| 68 | 68 | t.Fatal(err) | |
| 69 | 69 | } | |
| @@ -125,7 +125,7 @@ func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports. | |||
| 125 | 125 | dir := t.TempDir() | |
| 126 | 126 | events := make(chan ports.ClientEvent, 16) | |
| 127 | 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 | 129 | if err != nil { | |
| 130 | 130 | t.Fatal(err) | |
| 131 | 131 | } | |
| @@ -318,7 +318,7 @@ func TestUnbufferedEventsDoNotBlockDisplay(t *testing.T) { | |||
| 318 | 318 | } | |
| 319 | 319 | dir := t.TempDir() | |
| 320 | 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 | 322 | if err != nil { | |
| 323 | 323 | t.Fatal(err) | |
| 324 | 324 | } | |
| @@ -373,7 +373,7 @@ func TestWestonContents(t *testing.T) { | |||
| 373 | 373 | dir := t.TempDir() | |
| 374 | 374 | events := make(chan ports.ClientEvent, 16) | |
| 375 | 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 | 377 | if err != nil { | |
| 378 | 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 | 32 | role func(bool) | |
| 33 | 33 | destroyed bool | |
| 34 | 34 | 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 | 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 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 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 | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 17 | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 18 | 18 | "github.com/bnema/nefertty/internal/adapters/libinput" | |
| @@ -32,11 +32,13 @@ type Options struct { | |||
| 32 | 32 | Timeout time.Duration | |
| 33 | 33 | NoTerminal bool | |
| 34 | 34 | ScreenshotDir string | |
| 35 | - | // Width and Height size the headless output; 0 means 1920x1080. | |
| 36 | - | Width, Height int | |
| 35 | + | // Sizes are the headless outputs (width, height), left to right; empty | |
| 36 | + | // means one 1920x1080 output. With several outputs, screenshots go to | |
| 37 | + | // one subdirectory per output (HEADLESS-1, HEADLESS-2, ...). | |
| 38 | + | Sizes [][2]int | |
| 37 | 39 | // Run closes Script on shutdown; the reader goroutine exits after Close. | |
| 38 | 40 | Script io.ReadCloser | |
| 39 | - | testScenes chan<- ports.Scene | |
| 41 | + | testScenes chan<- []ports.Scene | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 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 | 53 | ctx, cancel = context.WithCancel(ctx) | |
| 52 | 54 | } | |
| 53 | 55 | defer cancel() | |
| 54 | - | width, height := opts.Width, opts.Height | |
| 55 | - | if width <= 0 || height <= 0 { | |
| 56 | - | width, height = 1920, 1080 | |
| 56 | + | sizes := opts.Sizes | |
| 57 | + | if len(sizes) == 0 { | |
| 58 | + | sizes = [][2]int{{1920, 1080}} | |
| 57 | 59 | } | |
| 58 | - | var outInfo wayland.OutputInfo | |
| 59 | 60 | var hw *drmBackend | |
| 60 | 61 | if opts.Backend == "drm" { | |
| 61 | 62 | var err error | |
| @@ -63,9 +64,6 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 63 | 64 | return err | |
| 64 | 65 | } | |
| 65 | 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 | 68 | client := make(chan ports.ClientEvent, 32) | |
| 71 | 69 | input := make(chan ports.InputEvent, 32) | |
| @@ -73,27 +71,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 73 | 71 | configChanges := make(chan ports.ConfigChanged, 8) | |
| 74 | 72 | commands := make(chan ports.ClientCommand, 32) | |
| 75 | 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 | 76 | configErrors := make(chan error, 8) | |
| 78 | - | renderScenes := make(chan ports.Scene, 1) | |
| 77 | + | renderScenes := make(chan []ports.Scene, 1) | |
| 79 | 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 | 80 | c, err := core.New(opts.Config, ch) | |
| 82 | 81 | if err != nil { | |
| 83 | 82 | 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 | 84 | km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options}) | |
| 91 | 85 | if err != nil { | |
| 92 | 86 | return err | |
| 93 | 87 | } | |
| 94 | 88 | keymap := km.String() | |
| 95 | 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 | 91 | if err != nil { | |
| 98 | 92 | km.Close() | |
| 99 | 93 | return err | |
| @@ -125,22 +119,24 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 125 | 119 | relayConfig(ctx, opts.Config, watched, configChanges, keymaps, commands, logging.For(ctx, "config")) | |
| 126 | 120 | }() | |
| 127 | 121 | script := make(chan string) | |
| 128 | - | // The software cursor starts centred, like libinput's pointer. | |
| 129 | - | softCursor := &headless.Cursor{} | |
| 130 | - | softCursor.Move(float64(width)/2, float64(height)/2) | |
| 122 | + | curs := newCursors() | |
| 131 | 123 | go func() { | |
| 132 | 124 | defer workers.Done() | |
| 133 | 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 | 134 | done <- safe("input", func() error { | |
| 135 | - | var move func(x, y float64) | |
| 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) | |
| 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) | |
| 140 | 136 | }) | |
| 141 | 137 | 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 | 141 | if opts.Script != nil { | |
| 146 | 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 | 165 | go func() { | |
| 170 | 166 | defer workers.Done() | |
| 171 | - | if hw != nil { | |
| 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) { | |
| 167 | + | newRenderer := func(w, h int) (ports.Renderer, error) { | |
| 184 | 168 | r, err := vulkan.New(w, h) | |
| 185 | 169 | if err != nil { | |
| 186 | 170 | return nil, err | |
| 187 | 171 | } | |
| 188 | 172 | 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 | 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 | 276 | log := logging.For(ctx, "core") | |
| 285 | 277 | configLog := logging.For(ctx, "config") | |
| 286 | 278 | for { | |
| 287 | 279 | select { | |
| 288 | 280 | case <-ctx.Done(): | |
| 289 | 281 | return | |
| 290 | - | case s := <-scenes: | |
| 282 | + | case set := <-scenes: | |
| 291 | 283 | if tap != nil { | |
| 292 | 284 | select { | |
| 293 | - | case tap <- s: | |
| 285 | + | case tap <- set: | |
| 294 | 286 | default: | |
| 295 | 287 | } | |
| 296 | 288 | } | |
| 297 | 289 | select { | |
| 298 | - | case renderScenes <- s: | |
| 290 | + | case renderScenes <- set: | |
| 299 | 291 | default: | |
| 300 | 292 | select { | |
| 301 | 293 | case <-renderScenes: | |
| 302 | 294 | default: | |
| 303 | 295 | } | |
| 304 | 296 | select { | |
| 305 | - | case renderScenes <- s: | |
| 297 | + | case renderScenes <- set: | |
| 306 | 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) | |
| 310 | - | rects := make([]string, 0, len(s.Windows)) | |
| 311 | - | for _, w := range 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)) | |
| 301 | + | for _, s := range set { | |
| 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) | |
| 303 | + | rects := make([]string, 0, len(s.Windows)) | |
| 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 | 309 | case err := <-configErrors: | |
| 316 | 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 | 108 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 109 | 109 | cfg := config.Defaults() | |
| 110 | 110 | cfg.Terminal.Command = []string{"weston-simple-shm"} | |
| 111 | - | scenes := make(chan ports.Scene, 64) | |
| 111 | + | scenes := make(chan []ports.Scene, 64) | |
| 112 | 112 | ctx, cancel := context.WithCancel(context.Background()) | |
| 113 | 113 | defer cancel() | |
| 114 | 114 | inputReady := make(chan chan<- ports.InputEvent, 1) | |
| @@ -140,7 +140,8 @@ func TestHeadlessSpawnClose(t *testing.T) { | |||
| 140 | 140 | defer timer.Stop() | |
| 141 | 141 | for { | |
| 142 | 142 | select { | |
| 143 | - | case s := <-scenes: | |
| 143 | + | case set := <-scenes: | |
| 144 | + | s := set[0] | |
| 144 | 145 | if len(s.Windows) == count { | |
| 145 | 146 | // A lone column fills the 1920 output (default zero gaps). | |
| 146 | 147 | if count == 1 && s.Windows[0].Rect.W != 1920 { | |
| @@ -221,7 +222,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 221 | 222 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 222 | 223 | cfg := config.Defaults() | |
| 223 | 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 | 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 | 227 | if err != nil { | |
| 227 | 228 | if strings.Contains(strings.ToLower(err.Error()), "vulkan") { | |
| @@ -231,7 +232,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) { | |||
| 231 | 232 | } | |
| 232 | 233 | two, focused := false, false | |
| 233 | 234 | for len(scenes) > 0 { | |
| 234 | - | s := <-scenes | |
| 235 | + | s := (<-scenes)[0] | |
| 235 | 236 | if len(s.Windows) == 2 { | |
| 236 | 237 | two = true | |
| 237 | 238 | for _, w := range s.Windows { | |
| @@ -310,3 +311,28 @@ func TestRelayConfigKeyboard(t *testing.T) { | |||
| 310 | 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 | 7 | "path/filepath" | |
| 8 | 8 | "runtime/debug" | |
| 9 | 9 | "sort" | |
| 10 | - | "strings" | |
| 10 | + | "sync" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 13 | 13 | "github.com/bnema/nefertty/internal/adapters/drm" | |
| 14 | 14 | "github.com/bnema/nefertty/internal/adapters/seat" | |
| 15 | - | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 16 | 15 | "github.com/bnema/nefertty/internal/logging" | |
| 17 | 16 | "github.com/bnema/nefertty/internal/ports" | |
| 17 | + | "github.com/bnema/zerowrap" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | var errTimeout = errors.New("timeout") | |
| 21 | 21 | ||
| 22 | + | // drmBackend is the seat and the cards with at least one usable output. | |
| 22 | 23 | type drmBackend struct { | |
| 23 | - | seat *seat.Seat | |
| 24 | - | out *drm.Output | |
| 25 | - | fd int | |
| 24 | + | seat *seat.Seat | |
| 25 | + | cards []*drmCard | |
| 26 | + | } | |
| 26 | 27 | ||
| 27 | - | inputActive, outputActive <-chan bool | |
| 28 | + | type drmCard struct { | |
| 29 | + | *drm.Card | |
| 30 | + | fd int | |
| 28 | 31 | } | |
| 29 | 32 | ||
| 30 | 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 | 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 | 45 | func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) { | |
| 42 | 46 | log := logging.For(ctx, "drm") | |
| 43 | 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 | 50 | return nil, fmt.Errorf("open seat: %w", err) | |
| 47 | 51 | } | |
| 48 | 52 | want := wantFromConfig(outputs) | |
| 49 | - | cards, _ := filepath.Glob("/dev/dri/card[0-9]*") | |
| 50 | - | sort.Strings(cards) | |
| 53 | + | paths, _ := filepath.Glob("/dev/dri/card[0-9]*") | |
| 54 | + | sort.Strings(paths) | |
| 55 | + | b := &drmBackend{seat: s} | |
| 51 | 56 | var errs []error | |
| 52 | - | // First pass: the configured output on any card; second: any usable output. | |
| 53 | - | passes := []drm.Want{want} | |
| 54 | - | if want.Name != "" { | |
| 55 | - | strict := want | |
| 56 | - | strict.Strict = true | |
| 57 | - | passes = []drm.Want{strict, want} | |
| 58 | - | } | |
| 59 | - | for _, w := range passes { | |
| 60 | - | for _, card := range cards { | |
| 61 | - | fd, err := s.OpenDevice(card) | |
| 62 | - | if err != nil { | |
| 63 | - | errs = append(errs, err) | |
| 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 | |
| 57 | + | for _, path := range paths { | |
| 58 | + | fd, err := s.OpenDevice(path) | |
| 59 | + | if err != nil { | |
| 60 | + | errs = append(errs, err) | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | card, err := drm.OpenCard(fd, path, want, log) | |
| 64 | + | if err != nil { | |
| 65 | + | log.Warn().Err(err).Str("card", path).Msg("card unusable") | |
| 66 | + | errs = append(errs, err) | |
| 67 | + | s.CloseDevice(fd) | |
| 68 | + | continue | |
| 76 | 69 | } | |
| 70 | + | b.cards = append(b.cards, &drmCard{Card: card, fd: fd}) | |
| 77 | 71 | } | |
| 78 | - | s.Close() | |
| 79 | - | err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...)) | |
| 80 | - | log.Error().Err(err).Msg("open drm") | |
| 81 | - | return nil, err | |
| 72 | + | if len(b.cards) == 0 { | |
| 73 | + | s.Close() | |
| 74 | + | err = fmt.Errorf("no usable DRM card in %v: %w", paths, errors.Join(errs...)) | |
| 75 | + | 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 | 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 | 84 | for _, o := range outputs { | |
| 88 | 85 | if o.Off { | |
| 89 | 86 | want.Disabled[o.Name] = true | |
| 90 | 87 | continue | |
| 91 | 88 | } | |
| 92 | - | if want.Name != "" || o.ScaleOnly { | |
| 93 | - | continue | |
| 94 | - | } | |
| 95 | - | want.Name = o.Name | |
| 96 | 89 | if o.Mode != "" { | |
| 97 | 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 | 95 | return want | |
| 102 | 96 | } | |
| 103 | 97 | ||
| 104 | - | // outputInfo describes the chosen output for wl_output and xdg-output. | |
| 105 | - | func (b *drmBackend) outputInfo() wayland.OutputInfo { | |
| 106 | - | i := b.out.Info() | |
| 107 | - | return wayland.OutputInfo{ | |
| 108 | - | Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model, | |
| 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, | |
| 98 | + | // close releases the cards and the seat so the TTY comes back. Outputs are | |
| 99 | + | // closed by their goroutines first. | |
| 100 | + | func (b *drmBackend) close() { | |
| 101 | + | for _, c := range b.cards { | |
| 102 | + | b.seat.CloseDevice(c.fd) | |
| 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. | |
| 115 | - | func (b *drmBackend) close() { | |
| 116 | - | b.out.Close() | |
| 117 | - | b.seat.CloseDevice(b.fd) | |
| 118 | - | b.seat.Close() | |
| 107 | + | // runOutputs drives every output of every card: one goroutine per output, | |
| 108 | + | // one flip reader per card, and a udev watcher that rescans connectors on | |
| 109 | + | // hotplug. Core learns about outputs through events. It returns when ctx | |
| 110 | + | // ends, after every output is closed. | |
| 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 | 24 | ActionFocusWorkspaceDown Action = "focus-workspace-down" | |
| 25 | 25 | ActionMoveToWorkspaceUp Action = "move-to-workspace-up" | |
| 26 | 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 | 32 | // Scale steps through the clean scales of the output (see CleanScales). | |
| 28 | 33 | ActionScaleUp Action = "scale-up" | |
| 29 | 34 | ActionScaleDown Action = "scale-down" | |
| @@ -77,6 +82,42 @@ type Effect struct { | |||
| 77 | 82 | 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 | 121 | // Apply runs a bind action on the monitor. | |
| 81 | 122 | func (m *Monitor) Apply(a Action) Effect { | |
| 82 | 123 | if n, move, ok := WorkspaceArg(a); ok { | |
M internal/core/core.go +184 -135
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "slices" | |
| 8 | 9 | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "unicode" | |
| @@ -12,16 +13,20 @@ import ( | |||
| 12 | 13 | ||
| 13 | 14 | var ErrQuit = errors.New("quit requested") | |
| 14 | 15 | ||
| 15 | - | // 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. | |
| 16 | + | // Channels connects the owner to adapters. Scenes must have capacity 1: core is | |
| 17 | + | // its only sender and may drain a stale set; consumers only receive. Each | |
| 18 | + | // send holds one scene per output. | |
| 17 | 19 | type Channels struct { | |
| 18 | - | Client <-chan ports.ClientEvent | |
| 19 | - | Input <-chan ports.InputEvent | |
| 20 | - | Output <-chan ports.OutputEvent | |
| 21 | - | Config <-chan ports.ConfigChanged | |
| 22 | - | Commands chan<- ports.ClientCommand | |
| 23 | - | Spawn chan<- ports.SpawnRequest | |
| 24 | - | Scenes chan ports.Scene | |
| 20 | + | Client <-chan ports.ClientEvent | |
| 21 | + | Input <-chan ports.InputEvent | |
| 22 | + | Output <-chan ports.OutputEvent | |
| 23 | + | Config <-chan ports.ConfigChanged | |
| 24 | + | Commands chan<- ports.ClientCommand | |
| 25 | + | Spawn chan<- ports.SpawnRequest | |
| 26 | + | 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 | 30 | ConfigErrors chan<- error | |
| 26 | 31 | } | |
| 27 | 32 | type binding struct { | |
| @@ -31,7 +36,8 @@ type binding struct { | |||
| 31 | 36 | type Core struct { | |
| 32 | 37 | ch Channels | |
| 33 | 38 | cfg ports.Config | |
| 34 | - | ws *Monitor | |
| 39 | + | screens []*screen | |
| 40 | + | focusScreen int | |
| 35 | 41 | binds map[binding]Action | |
| 36 | 42 | pressed map[string]bool | |
| 37 | 43 | sent map[WindowID]ports.ConfigureWindow | |
| @@ -39,18 +45,15 @@ type Core struct { | |||
| 39 | 45 | pointer WindowID | |
| 40 | 46 | grab WindowID | |
| 41 | 47 | buttons map[uint32]bool | |
| 42 | - | cursorX, cursorY float64 | |
| 48 | + | cursorX, cursorY float64 // global, logical | |
| 43 | 49 | seq uint64 | |
| 44 | - | layers []ports.LayerSurface | |
| 45 | - | placed []ports.SceneLayer | |
| 46 | - | layerChanged bool | |
| 47 | - | // Output in physical pixels. scale comes from output.<name>.scale and | |
| 48 | - | // changes live with scale-up/scale-down until the config value changes. | |
| 49 | - | outName string | |
| 50 | - | physW, physH int | |
| 51 | - | scale, cfgScale float64 | |
| 52 | - | sentScale ports.SetOutputScale | |
| 53 | - | slots map[slotKey]*slotState | |
| 50 | + | // layerChanged is set once layer state arrives from wayland. | |
| 51 | + | layerChanged bool | |
| 52 | + | sentOutputs ports.SetOutputs | |
| 53 | + | specs []NamedWorkspace | |
| 54 | + | presets []Width | |
| 55 | + | overflow Overflow | |
| 56 | + | slots map[slotKey]*slotState | |
| 54 | 57 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 55 | 58 | toSpawn []slotKey | |
| 56 | 59 | sentPending bool | |
| @@ -140,7 +143,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 140 | 143 | continue | |
| 141 | 144 | } | |
| 142 | 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 | 147 | default: | |
| 145 | 148 | return fmt.Errorf("invalid action %q", a) | |
| 146 | 149 | } | |
| @@ -171,7 +174,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 171 | 174 | return fmt.Errorf("invalid workspace %q", ws.Name) | |
| 172 | 175 | } | |
| 173 | 176 | 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 | 179 | specs, err := parseSlots(cfg.Workspaces) | |
| 177 | 180 | if err != nil { | |
| @@ -179,13 +182,13 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 179 | 182 | } | |
| 180 | 183 | c.cfg = cfg | |
| 181 | 184 | c.binds = binds | |
| 182 | - | c.applyConfigScale() | |
| 185 | + | c.specs, c.presets, c.overflow = named, presets, defOverflow | |
| 183 | 186 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 184 | - | c.ws.SetNamed(named) | |
| 185 | - | c.ws.SetOverflow(defOverflow) | |
| 186 | - | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | |
| 187 | - | c.ws.SetPresets(presets) | |
| 188 | - | c.ws.SetGaps(cfg.Layout.Gaps) | |
| 187 | + | c.named() | |
| 188 | + | for _, s := range c.screens { | |
| 189 | + | c.settings(s.mon) | |
| 190 | + | } | |
| 191 | + | c.applyConfigScales() | |
| 189 | 192 | return nil | |
| 190 | 193 | } | |
| 191 | 194 | ||
| @@ -232,10 +235,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||
| 232 | 235 | return nil | |
| 233 | 236 | } | |
| 234 | 237 | func New(cfg ports.Config, ch Channels) (*Core, error) { | |
| 235 | - | if cap(ch.Scenes) != 1 { | |
| 236 | - | return nil, fmt.Errorf("scenes must have capacity 1") | |
| 238 | + | if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 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 | 243 | if err := c.apply(cfg); err != nil { | |
| 240 | 244 | 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 | 257 | // clientRect is the placement minus the border drawn around the client. | |
| 281 | 258 | // The renderer applies the same inset (ports.Border). | |
| 282 | 259 | func (c *Core) clientRect(p Placement) Rect { | |
| @@ -288,69 +265,104 @@ func (c *Core) clientRect(p Placement) Rect { | |||
| 288 | 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 | 269 | func (c *Core) visible(id WindowID) bool { | |
| 293 | - | for _, p := range c.ws.Layout() { | |
| 294 | - | if p.ID == id { | |
| 295 | - | return !p.Hidden | |
| 270 | + | for _, sc := range c.screens { | |
| 271 | + | for _, p := range sc.mon.Layout() { | |
| 272 | + | if p.ID == id { | |
| 273 | + | return !p.Hidden | |
| 274 | + | } | |
| 296 | 275 | } | |
| 297 | 276 | } | |
| 298 | 277 | return false | |
| 299 | 278 | } | |
| 300 | 279 | ||
| 301 | 280 | // keyboardFocus is the mapped top/overlay layer with exclusive keyboard | |
| 302 | - | // interactivity and the highest ID, else the focused window. On-demand layers | |
| 303 | - | // never take focus automatically. When the layer unmaps, focus returns to the window. | |
| 281 | + | // interactivity and the highest ID on any output, else the focused window of | |
| 282 | + | // the focused output. On-demand layers never take focus automatically. When | |
| 283 | + | // the layer unmaps, focus returns to the window. | |
| 304 | 284 | func (c *Core) keyboardFocus() WindowID { | |
| 305 | 285 | var layer WindowID | |
| 306 | - | for _, l := range c.layers { | |
| 307 | - | if l.Keyboard == 1 && (l.Layer == ports.LayerTop || l.Layer == ports.LayerOverlay) && l.ID > layer { | |
| 308 | - | layer = l.ID | |
| 286 | + | for _, sc := range c.screens { | |
| 287 | + | for _, l := range sc.layers { | |
| 288 | + | 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 | 293 | if layer != 0 { | |
| 312 | 294 | return layer | |
| 313 | 295 | } | |
| 314 | - | id, _ := c.ws.Focused() | |
| 296 | + | id, _ := c.cur().mon.Focused() | |
| 315 | 297 | 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 | 318 | func (c *Core) publish(ctx context.Context) error { | |
| 319 | - | // Clients learn the new scale before the configures sized for it. | |
| 320 | - | if v := (ports.SetOutputScale{Scale: c.scale, Width: c.ws.Output().W, Height: c.ws.Output().H}); v != c.sentScale { | |
| 319 | + | // Clients learn outputs and scales before the configures sized for them. | |
| 320 | + | if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) { | |
| 321 | 321 | if err := c.command(ctx, v); err != nil { | |
| 322 | 322 | return err | |
| 323 | 323 | } | |
| 324 | - | c.sentScale = v | |
| 324 | + | 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 | 329 | if err := c.publishPending(ctx); err != nil { | |
| 327 | 330 | 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 | 332 | alive := map[WindowID]bool{} | |
| 332 | 333 | focus := c.keyboardFocus() | |
| 333 | - | for _, p := range c.ws.Layout() { | |
| 334 | - | alive[p.ID] = true | |
| 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 | - | if p.Hidden { | |
| 337 | - | if old, ok := c.sent[p.ID]; ok && old.Activated { | |
| 338 | - | old.Activated = false | |
| 339 | - | if err := c.command(ctx, old); err != nil { | |
| 340 | - | return err | |
| 334 | + | scenes := make([]ports.Scene, 0, len(c.screens)) | |
| 335 | + | // Before the first output (and after the last is unplugged) the | |
| 336 | + | // placeholder's scene has no output name: no renderer draws it. | |
| 337 | + | for i, sc := range c.screens { | |
| 338 | + | c.seq++ | |
| 339 | + | o := sc.mon.Output() | |
| 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...)} | |
| 341 | + | 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] = old | |
| 354 | + | continue | |
| 343 | 355 | } | |
| 344 | - | continue | |
| 345 | - | } | |
| 346 | - | r := c.clientRect(p) | |
| 347 | - | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: p.Focused} | |
| 348 | - | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 349 | - | if err := c.command(ctx, v); err != nil { | |
| 350 | - | return err | |
| 356 | + | r := c.clientRect(p) | |
| 357 | + | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Output: sc.name()} | |
| 358 | + | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 359 | + | if err := c.command(ctx, v); err != nil { | |
| 360 | + | return err | |
| 361 | + | } | |
| 362 | + | c.sent[p.ID] = v | |
| 351 | 363 | } | |
| 352 | - | c.sent[p.ID] = v | |
| 353 | 364 | } | |
| 365 | + | scenes = append(scenes, scene) | |
| 354 | 366 | } | |
| 355 | 367 | for id := range c.sent { | |
| 356 | 368 | if !alive[id] { | |
| @@ -373,21 +385,65 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 373 | 385 | } | |
| 374 | 386 | 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 | 395 | select { | |
| 378 | - | case c.ch.Scenes <- scene: | |
| 396 | + | case ch <- v: | |
| 379 | 397 | default: | |
| 380 | 398 | select { | |
| 381 | - | case <-c.ch.Scenes: | |
| 399 | + | case <-ch: | |
| 382 | 400 | default: | |
| 383 | 401 | } | |
| 384 | 402 | select { | |
| 385 | - | case c.ch.Scenes <- scene: | |
| 403 | + | case ch <- v: | |
| 386 | 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 | 447 | func (c *Core) Run(ctx context.Context) error { | |
| 392 | 448 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | |
| 393 | 449 | return nil | |
| @@ -403,17 +459,18 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 403 | 459 | } | |
| 404 | 460 | switch v := ev.(type) { | |
| 405 | 461 | case ports.LayerChanged: | |
| 406 | - | c.layers = append([]ports.LayerSurface(nil), v.Layers...) | |
| 407 | 462 | c.layerChanged = true | |
| 408 | - | var usable ports.Rect | |
| 409 | - | c.placed, usable = arrangeLayers(c.ws.Output().W, c.ws.Output().H, c.layers) | |
| 410 | - | c.ws.SetUsable(usable) | |
| 463 | + | c.setLayers(v.Layers) | |
| 411 | 464 | case ports.WindowMapped: | |
| 412 | 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 | 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 | 474 | c.releaseSlots() | |
| 418 | 475 | if c.pointer == v.ID { | |
| 419 | 476 | c.pointer = 0 | |
| @@ -422,7 +479,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 422 | 479 | } | |
| 423 | 480 | } | |
| 424 | 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 | 486 | if err := c.publish(ctx); err != nil { | |
| 428 | 487 | return nil | |
| @@ -433,18 +492,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 433 | 492 | continue | |
| 434 | 493 | } | |
| 435 | 494 | switch v := ev.(type) { | |
| 436 | - | case ports.OutputMode: | |
| 437 | - | c.physW, c.physH = v.Width, v.Height | |
| 438 | - | if v.Name != c.outName { | |
| 439 | - | c.outName, c.cfgScale = v.Name, 0 | |
| 495 | + | case ports.OutputAdded: | |
| 496 | + | c.addScreen(v.Info) | |
| 497 | + | if c.layerChanged { | |
| 498 | + | c.setLayers(c.allLayers()) | |
| 440 | 499 | } | |
| 441 | - | c.applyConfigScale() | |
| 442 | - | c.setScale(c.scale) | |
| 443 | - | case ports.OutputUsable: | |
| 444 | - | // Once layer state arrives, its exclusive zones take precedence. | |
| 445 | - | if !c.layerChanged { | |
| 446 | - | c.ws.SetUsable(v.Rect) | |
| 500 | + | case ports.OutputRemoved: | |
| 501 | + | c.removeScreen(v.Name) | |
| 502 | + | if c.layerChanged { | |
| 503 | + | c.setLayers(c.allLayers()) | |
| 447 | 504 | } | |
| 505 | + | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, c.cursorX, c.cursorY) | |
| 448 | 506 | } | |
| 449 | 507 | if err := c.publish(ctx); err != nil { | |
| 450 | 508 | return nil | |
| @@ -474,23 +532,8 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 474 | 532 | } | |
| 475 | 533 | switch v := ev.(type) { | |
| 476 | 534 | case ports.PointerMotion: | |
| 477 | - | // Input reports physical pixels; hit-testing is logical. | |
| 478 | - | c.cursorX = min(max(v.X/c.scale, 0), float64(max(c.ws.Output().W-1, 0))) | |
| 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 | - | } | |
| 535 | + | c.cursorX, c.cursorY = c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y) | |
| 536 | + | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| 494 | 537 | // Layout changes are intentionally re-hit-tested only on motion. | |
| 495 | 538 | if id != c.pointer { | |
| 496 | 539 | c.pointer = id | |
| @@ -521,7 +564,10 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 521 | 564 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| 522 | 565 | 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 | 571 | if err := c.publish(ctx); err != nil { | |
| 526 | 572 | return nil | |
| 527 | 573 | } | |
| @@ -566,14 +612,16 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 566 | 612 | if action == ActionScaleDown { | |
| 567 | 613 | 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 | 618 | if err := c.publish(ctx); err != nil { | |
| 571 | 619 | return nil | |
| 572 | 620 | } | |
| 573 | 621 | continue | |
| 574 | 622 | } | |
| 575 | - | before := c.ws.Current() | |
| 576 | - | effect := c.ws.Apply(action) | |
| 623 | + | before := c.cur().mon.Current() | |
| 624 | + | effect := c.applyAction(action) | |
| 577 | 625 | if effect.Quit { | |
| 578 | 626 | return ErrQuit | |
| 579 | 627 | } | |
| @@ -590,7 +638,8 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 590 | 638 | } | |
| 591 | 639 | // Showing a declared workspace refills its empty slots. | |
| 592 | 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 | 643 | return nil | |
| 595 | 644 | } | |
| 596 | 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 | 31 | // command receives the next client command, skipping output scale updates. | |
| 26 | 32 | func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |
| 27 | 33 | t.Helper() | |
| 28 | 34 | for { | |
| 29 | 35 | if v := receive(t, ch); v != nil { | |
| 30 | - | if _, ok := v.(ports.SetOutputScale); !ok { | |
| 36 | + | if _, ok := v.(ports.SetOutputs); !ok { | |
| 31 | 37 | return v | |
| 32 | 38 | } | |
| 33 | 39 | } | |
| @@ -45,7 +51,7 @@ func TestOwner(t *testing.T) { | |||
| 45 | 51 | reload := make(chan ports.ConfigChanged, 8) | |
| 46 | 52 | commands := make(chan ports.ClientCommand, 256) | |
| 47 | 53 | spawn := make(chan ports.SpawnRequest, 8) | |
| 48 | - | scenes := make(chan ports.Scene, 1) | |
| 54 | + | scenes := make(chan []ports.Scene, 1) | |
| 49 | 55 | errs := make(chan error, 8) | |
| 50 | 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 | 57 | if err != nil { | |
| @@ -55,23 +61,23 @@ func TestOwner(t *testing.T) { | |||
| 55 | 61 | defer cancel() | |
| 56 | 62 | done := make(chan error, 1) | |
| 57 | 63 | go func() { done <- c.Run(ctx) }() | |
| 58 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 59 | - | s := receive(t, scenes) | |
| 64 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 65 | + | s := scene(t, scenes) | |
| 60 | 66 | if s.OutputWidth != 100 { | |
| 61 | 67 | t.Fatal(s) | |
| 62 | 68 | } | |
| 63 | 69 | client <- ports.WindowMapped{ID: 1} | |
| 64 | - | s = receive(t, scenes) | |
| 70 | + | s = scene(t, scenes) | |
| 65 | 71 | // A single column fills the usable width. | |
| 66 | 72 | if len(s.Windows) != 1 || s.Windows[0].Rect.W != 84 || !s.Windows[0].Borderless { | |
| 67 | 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 | 76 | t.Fatal(v) | |
| 71 | 77 | } | |
| 72 | 78 | command(t, commands) | |
| 73 | 79 | client <- ports.WindowMapped{ID: 2} | |
| 74 | - | s = receive(t, scenes) | |
| 80 | + | s = scene(t, scenes) | |
| 75 | 81 | if len(s.Windows) != 2 { | |
| 76 | 82 | t.Fatal(s) | |
| 77 | 83 | } | |
| @@ -80,12 +86,12 @@ func TestOwner(t *testing.T) { | |||
| 80 | 86 | <-commands | |
| 81 | 87 | } | |
| 82 | 88 | client <- ports.WindowMapped{ID: 2} | |
| 83 | - | receive(t, scenes) | |
| 89 | + | scene(t, scenes) | |
| 84 | 90 | if len(commands) != 0 { | |
| 85 | 91 | t.Fatal("duplicate configure") | |
| 86 | 92 | } | |
| 87 | 93 | input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModAlt, Pressed: true} | |
| 88 | - | receive(t, scenes) | |
| 94 | + | scene(t, scenes) | |
| 89 | 95 | if v := receive(t, spawn); len(v.Argv) != 1 || v.Argv[0] != "foot" { | |
| 90 | 96 | t.Fatal(v) | |
| 91 | 97 | } | |
| @@ -95,22 +101,22 @@ func TestOwner(t *testing.T) { | |||
| 95 | 101 | t.Fatal(v) | |
| 96 | 102 | } | |
| 97 | 103 | input <- ports.KeyEvent{Keysym: "Q", Mods: ports.ModAlt, Pressed: true} | |
| 98 | - | receive(t, scenes) | |
| 104 | + | scene(t, scenes) | |
| 99 | 105 | if v := command(t, commands); v != (ports.CloseWindow{ID: 2}) { | |
| 100 | 106 | t.Fatal(v) | |
| 101 | 107 | } | |
| 102 | 108 | client <- ports.WindowUnmapped{ID: 2} | |
| 103 | - | receive(t, scenes) | |
| 109 | + | scene(t, scenes) | |
| 104 | 110 | for len(commands) > 0 { | |
| 105 | 111 | <-commands | |
| 106 | 112 | } | |
| 107 | 113 | cfg.Layout.Gaps = 4 | |
| 108 | 114 | reload <- ports.ConfigChanged{Config: cfg} | |
| 109 | - | s = receive(t, scenes) | |
| 115 | + | s = scene(t, scenes) | |
| 110 | 116 | if s.Windows[0].Rect.W != 92 { | |
| 111 | 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 | 120 | t.Fatal(v) | |
| 115 | 121 | } | |
| 116 | 122 | cfg.Layout.MaxColumns = 0 | |
| @@ -126,11 +132,11 @@ func TestOwner(t *testing.T) { | |||
| 126 | 132 | } | |
| 127 | 133 | func TestCancelAndSceneCapacity(t *testing.T) { | |
| 128 | 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 | 136 | t.Fatal("capacity") | |
| 131 | 137 | } | |
| 132 | 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 | 140 | if err != nil { | |
| 135 | 141 | t.Fatal(err) | |
| 136 | 142 | } | |
| @@ -147,7 +153,7 @@ func TestKeyPressState(t *testing.T) { | |||
| 147 | 153 | client := make(chan ports.ClientEvent, 8) | |
| 148 | 154 | input := make(chan ports.InputEvent, 8) | |
| 149 | 155 | commands := make(chan ports.ClientCommand, 16) | |
| 150 | - | scenes := make(chan ports.Scene, 1) | |
| 156 | + | scenes := make(chan []ports.Scene, 1) | |
| 151 | 157 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Commands: commands, Scenes: scenes}) | |
| 152 | 158 | if err != nil { | |
| 153 | 159 | t.Fatal(err) | |
| @@ -157,12 +163,12 @@ func TestKeyPressState(t *testing.T) { | |||
| 157 | 163 | go func() { done <- c.Run(ctx) }() | |
| 158 | 164 | defer func() { cancel(); receive(t, done) }() | |
| 159 | 165 | client <- ports.WindowMapped{ID: 1} | |
| 160 | - | receive(t, scenes) | |
| 166 | + | scene(t, scenes) | |
| 161 | 167 | command(t, commands) | |
| 162 | 168 | command(t, commands) | |
| 163 | 169 | press := ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 164 | 170 | input <- press | |
| 165 | - | receive(t, scenes) | |
| 171 | + | scene(t, scenes) | |
| 166 | 172 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper} | |
| 167 | 173 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 168 | 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 | 179 | t.Fatal(v) | |
| 174 | 180 | } | |
| 175 | 181 | input <- press | |
| 176 | - | receive(t, scenes) | |
| 182 | + | scene(t, scenes) | |
| 177 | 183 | input <- ports.KeyEvent{Keysym: "Left", Pressed: true} | |
| 178 | 184 | command(t, commands) | |
| 179 | 185 | input <- ports.KeyEvent{Keysym: "Left"} | |
| @@ -187,7 +193,7 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 187 | 193 | input := make(chan ports.InputEvent, 4) | |
| 188 | 194 | client := make(chan ports.ClientEvent, 2) | |
| 189 | 195 | commands := make(chan ports.ClientCommand, 8) | |
| 190 | - | scenes := make(chan ports.Scene, 1) | |
| 196 | + | scenes := make(chan []ports.Scene, 1) | |
| 191 | 197 | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |
| 192 | 198 | if err != nil { | |
| 193 | 199 | t.Fatal(err) | |
| @@ -197,11 +203,11 @@ func TestBoundReleaseSwallowed(t *testing.T) { | |||
| 197 | 203 | go func() { done <- c.Run(ctx) }() | |
| 198 | 204 | defer func() { cancel(); receive(t, done) }() | |
| 199 | 205 | client <- ports.WindowMapped{ID: 1} | |
| 200 | - | receive(t, scenes) | |
| 206 | + | scene(t, scenes) | |
| 201 | 207 | command(t, commands) | |
| 202 | 208 | command(t, commands) | |
| 203 | 209 | input <- ports.KeyEvent{Keysym: "Left", Mods: ports.ModSuper, Pressed: true} | |
| 204 | - | receive(t, scenes) | |
| 210 | + | scene(t, scenes) | |
| 205 | 211 | input <- ports.KeyEvent{Keysym: "Left"} | |
| 206 | 212 | input <- ports.KeyEvent{Keysym: "x", Pressed: true} | |
| 207 | 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 | 223 | input := make(chan ports.InputEvent, 8) | |
| 218 | 224 | output := make(chan ports.OutputEvent, 8) | |
| 219 | 225 | commands := make(chan ports.ClientCommand, 64) | |
| 220 | - | scenes := make(chan ports.Scene, 1) | |
| 226 | + | scenes := make(chan []ports.Scene, 1) | |
| 221 | 227 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 222 | 228 | if err != nil { | |
| 223 | 229 | t.Fatal(err) | |
| @@ -225,12 +231,12 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 225 | 231 | ctx, cancel := context.WithCancel(context.Background()) | |
| 226 | 232 | defer cancel() | |
| 227 | 233 | go c.Run(ctx) | |
| 228 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 229 | - | receive(t, scenes) | |
| 234 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 235 | + | scene(t, scenes) | |
| 230 | 236 | client <- ports.WindowMapped{ID: 1} | |
| 231 | - | first := receive(t, scenes).Windows[0].Rect | |
| 237 | + | first := scene(t, scenes).Windows[0].Rect | |
| 232 | 238 | client <- ports.WindowMapped{ID: 2} | |
| 233 | - | receive(t, scenes) | |
| 239 | + | scene(t, scenes) | |
| 234 | 240 | for len(commands) > 0 { | |
| 235 | 241 | <-commands | |
| 236 | 242 | } | |
| @@ -245,7 +251,7 @@ func TestPointerFocusAndGrab(t *testing.T) { | |||
| 245 | 251 | if v := command(t, commands); v != (ports.PointerButtonTo{ID: 1, Button: 0x110, Pressed: true}) { | |
| 246 | 252 | t.Fatal(v) | |
| 247 | 253 | } | |
| 248 | - | receive(t, scenes) | |
| 254 | + | scene(t, scenes) | |
| 249 | 255 | found := false | |
| 250 | 256 | for len(commands) > 0 { | |
| 251 | 257 | if v, ok := (<-commands).(ports.FocusWindow); ok && v.ID == 1 { | |
| @@ -272,7 +278,7 @@ func TestBorderInset(t *testing.T) { | |||
| 272 | 278 | input := make(chan ports.InputEvent, 8) | |
| 273 | 279 | output := make(chan ports.OutputEvent, 8) | |
| 274 | 280 | commands := make(chan ports.ClientCommand, 64) | |
| 275 | - | scenes := make(chan ports.Scene, 1) | |
| 281 | + | scenes := make(chan []ports.Scene, 1) | |
| 276 | 282 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 277 | 283 | if err != nil { | |
| 278 | 284 | t.Fatal(err) | |
| @@ -280,23 +286,23 @@ func TestBorderInset(t *testing.T) { | |||
| 280 | 286 | ctx, cancel := context.WithCancel(context.Background()) | |
| 281 | 287 | defer cancel() | |
| 282 | 288 | go c.Run(ctx) | |
| 283 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 284 | - | receive(t, scenes) | |
| 289 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 290 | + | scene(t, scenes) | |
| 285 | 291 | // Alone, the window is borderless and gets the full rect. | |
| 286 | 292 | client <- ports.WindowMapped{ID: 1} | |
| 287 | - | r := receive(t, scenes).Windows[0].Rect | |
| 288 | - | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true}) { | |
| 293 | + | r := scene(t, scenes).Windows[0].Rect | |
| 294 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: r.W, Height: r.H, Activated: true, Output: "OUT-1"}) { | |
| 289 | 295 | t.Fatalf("%v for outer %v", v, r) | |
| 290 | 296 | } | |
| 291 | 297 | // With a second column, the scene keeps the outer rect and the client is | |
| 292 | 298 | // configured 2px smaller on each side. | |
| 293 | 299 | client <- ports.WindowMapped{ID: 2} | |
| 294 | - | s := receive(t, scenes) | |
| 300 | + | s := scene(t, scenes) | |
| 295 | 301 | r = s.Windows[1].Rect | |
| 296 | 302 | if s.Windows[1].ID != 2 || s.Windows[1].Borderless { | |
| 297 | 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 | 306 | found := false | |
| 301 | 307 | for len(commands) > 0 { | |
| 302 | 308 | if v := <-commands; v == want { | |
| @@ -318,7 +324,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 318 | 324 | input := make(chan ports.InputEvent, 8) | |
| 319 | 325 | output := make(chan ports.OutputEvent, 8) | |
| 320 | 326 | commands := make(chan ports.ClientCommand, 64) | |
| 321 | - | scenes := make(chan ports.Scene, 1) | |
| 327 | + | scenes := make(chan []ports.Scene, 1) | |
| 322 | 328 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 323 | 329 | if err != nil { | |
| 324 | 330 | t.Fatal(err) | |
| @@ -326,10 +332,10 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 326 | 332 | ctx, cancel := context.WithCancel(context.Background()) | |
| 327 | 333 | defer cancel() | |
| 328 | 334 | go c.Run(ctx) | |
| 329 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 330 | - | receive(t, scenes) | |
| 335 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 336 | + | scene(t, scenes) | |
| 331 | 337 | client <- ports.WindowMapped{ID: 1} | |
| 332 | - | receive(t, scenes) | |
| 338 | + | scene(t, scenes) | |
| 333 | 339 | focusOf := func() ports.WindowID { | |
| 334 | 340 | t.Helper() | |
| 335 | 341 | for { | |
| @@ -345,7 +351,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 345 | 351 | launcher := ports.LayerSurface{ID: 7, Layer: ports.LayerOverlay, Width: 40, Height: 20, Keyboard: 1} | |
| 346 | 352 | bar := ports.LayerSurface{ID: 6, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 5, Keyboard: 2} | |
| 347 | 353 | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar, launcher}} | |
| 348 | - | receive(t, scenes) | |
| 354 | + | scene(t, scenes) | |
| 349 | 355 | if id := focusOf(); id != 7 { | |
| 350 | 356 | t.Fatalf("layer focus %d", id) | |
| 351 | 357 | } | |
| @@ -359,7 +365,7 @@ func TestLayerKeyboardFocus(t *testing.T) { | |||
| 359 | 365 | } | |
| 360 | 366 | } | |
| 361 | 367 | client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}} | |
| 362 | - | receive(t, scenes) | |
| 368 | + | scene(t, scenes) | |
| 363 | 369 | if id := focusOf(); id != 1 { | |
| 364 | 370 | t.Fatalf("focus not restored: %d", id) | |
| 365 | 371 | } | |
| @@ -371,7 +377,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 371 | 377 | input := make(chan ports.InputEvent, 8) | |
| 372 | 378 | output := make(chan ports.OutputEvent, 8) | |
| 373 | 379 | commands := make(chan ports.ClientCommand, 64) | |
| 374 | - | scenes := make(chan ports.Scene, 1) | |
| 380 | + | scenes := make(chan []ports.Scene, 1) | |
| 375 | 381 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 376 | 382 | if err != nil { | |
| 377 | 383 | t.Fatal(err) | |
| @@ -379,16 +385,16 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 379 | 385 | ctx, cancel := context.WithCancel(context.Background()) | |
| 380 | 386 | defer cancel() | |
| 381 | 387 | go c.Run(ctx) | |
| 382 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 383 | - | receive(t, scenes) | |
| 388 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 389 | + | scene(t, scenes) | |
| 384 | 390 | client <- ports.WindowMapped{ID: 1} | |
| 385 | - | receive(t, scenes) | |
| 391 | + | scene(t, scenes) | |
| 386 | 392 | for len(commands) > 0 { | |
| 387 | 393 | <-commands | |
| 388 | 394 | } | |
| 389 | 395 | // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3). | |
| 390 | 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 | 398 | if len(s.Windows) != 1 || !s.Windows[0].Hidden { | |
| 393 | 399 | t.Fatal(s) | |
| 394 | 400 | } | |
| @@ -407,7 +413,7 @@ func TestWorkspaceSwitch(t *testing.T) { | |||
| 407 | 413 | } | |
| 408 | 414 | // A new window opens on workspace 2, alone and borderless. | |
| 409 | 415 | client <- ports.WindowMapped{ID: 2} | |
| 410 | - | s = receive(t, scenes) | |
| 416 | + | s = scene(t, scenes) | |
| 411 | 417 | if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || !s.Windows[1].Borderless { | |
| 412 | 418 | t.Fatal(s) | |
| 413 | 419 | } | |
| @@ -419,7 +425,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 419 | 425 | input := make(chan ports.InputEvent, 8) | |
| 420 | 426 | output := make(chan ports.OutputEvent, 8) | |
| 421 | 427 | commands := make(chan ports.ClientCommand, 64) | |
| 422 | - | scenes := make(chan ports.Scene, 1) | |
| 428 | + | scenes := make(chan []ports.Scene, 1) | |
| 423 | 429 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 424 | 430 | if err != nil { | |
| 425 | 431 | t.Fatal(err) | |
| @@ -427,10 +433,10 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 427 | 433 | ctx, cancel := context.WithCancel(context.Background()) | |
| 428 | 434 | defer cancel() | |
| 429 | 435 | go c.Run(ctx) | |
| 430 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 431 | - | receive(t, scenes) | |
| 436 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 437 | + | scene(t, scenes) | |
| 432 | 438 | client <- ports.WindowMapped{ID: 1} | |
| 433 | - | receive(t, scenes) | |
| 439 | + | scene(t, scenes) | |
| 434 | 440 | input <- ports.PointerMotion{X: 50, Y: 40} | |
| 435 | 441 | for len(commands) < 3 { | |
| 436 | 442 | time.Sleep(time.Millisecond) | |
| @@ -440,7 +446,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 440 | 446 | } | |
| 441 | 447 | // Cmd+2 hides window 1; the pointer must leave it. | |
| 442 | 448 | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |
| 443 | - | receive(t, scenes) | |
| 449 | + | scene(t, scenes) | |
| 444 | 450 | var cleared bool | |
| 445 | 451 | for len(commands) > 0 { | |
| 446 | 452 | if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 { | |
| @@ -454,7 +460,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) { | |||
| 454 | 460 | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 455 | 461 | input <- ports.PointerButton{Button: 0x110} | |
| 456 | 462 | client <- ports.WindowMapped{ID: 2} | |
| 457 | - | s := receive(t, scenes) | |
| 463 | + | s := scene(t, scenes) | |
| 458 | 464 | if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden { | |
| 459 | 465 | t.Fatal(s) | |
| 460 | 466 | } | |
| @@ -470,7 +476,7 @@ func TestShiftReleasedFirst(t *testing.T) { | |||
| 470 | 476 | input := make(chan ports.InputEvent, 8) | |
| 471 | 477 | client := make(chan ports.ClientEvent, 2) | |
| 472 | 478 | commands := make(chan ports.ClientCommand, 16) | |
| 473 | - | scenes := make(chan ports.Scene, 1) | |
| 479 | + | scenes := make(chan []ports.Scene, 1) | |
| 474 | 480 | c, err := core.New(cfg, core.Channels{Input: input, Client: client, Commands: commands, Scenes: scenes}) | |
| 475 | 481 | if err != nil { | |
| 476 | 482 | t.Fatal(err) | |
| @@ -479,13 +485,13 @@ func TestShiftReleasedFirst(t *testing.T) { | |||
| 479 | 485 | defer cancel() | |
| 480 | 486 | go c.Run(ctx) | |
| 481 | 487 | client <- ports.WindowMapped{ID: 1} | |
| 482 | - | receive(t, scenes) | |
| 488 | + | scene(t, scenes) | |
| 483 | 489 | for len(commands) > 0 { | |
| 484 | 490 | <-commands | |
| 485 | 491 | } | |
| 486 | 492 | // US Cmd+Shift+1 prints exclam; the physical key (code 2) matches move-to-workspace 1. | |
| 487 | 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 | 495 | // Shift goes up first, so the release reports "1": still swallowed. | |
| 490 | 496 | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper} | |
| 491 | 497 | input <- ports.KeyEvent{Keysym: "x", Keycode: 45, Pressed: true} | |
| @@ -505,7 +511,7 @@ func TestOutputScale(t *testing.T) { | |||
| 505 | 511 | output := make(chan ports.OutputEvent, 8) | |
| 506 | 512 | reload := make(chan ports.ConfigChanged, 1) | |
| 507 | 513 | commands := make(chan ports.ClientCommand, 64) | |
| 508 | - | scenes := make(chan ports.Scene, 1) | |
| 514 | + | scenes := make(chan []ports.Scene, 1) | |
| 509 | 515 | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Config: reload, Commands: commands, Scenes: scenes}) | |
| 510 | 516 | if err != nil { | |
| 511 | 517 | t.Fatal(err) | |
| @@ -514,53 +520,53 @@ func TestOutputScale(t *testing.T) { | |||
| 514 | 520 | defer cancel() | |
| 515 | 521 | go c.Run(ctx) | |
| 516 | 522 | // 5120x2160 at scale 2 is 2560x1080 logical. | |
| 517 | - | output <- ports.OutputMode{Width: 5120, Height: 2160, Name: "DP-2"} | |
| 518 | - | s := receive(t, scenes) | |
| 523 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "DP-2", Width: 5120, Height: 2160}} | |
| 524 | + | s := scene(t, scenes) | |
| 519 | 525 | if s.Scale != 2 || s.OutputWidth != 2560 || s.OutputHeight != 1080 { | |
| 520 | 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 | 529 | t.Fatal(v) | |
| 524 | 530 | } | |
| 525 | 531 | client <- ports.WindowMapped{ID: 1} | |
| 526 | - | receive(t, scenes) | |
| 527 | - | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true}) { | |
| 532 | + | scene(t, scenes) | |
| 533 | + | if v := command(t, commands); v != (ports.ConfigureWindow{ID: 1, Width: 2560, Height: 1080, Activated: true, Output: "DP-2"}) { | |
| 528 | 534 | t.Fatal(v) | |
| 529 | 535 | } | |
| 530 | 536 | for len(commands) > 0 { | |
| 531 | 537 | <-commands | |
| 532 | 538 | } | |
| 533 | - | // The pointer arrives in physical pixels; the client sees logical ones. | |
| 534 | - | input <- ports.PointerMotion{X: 1000, Y: 400} | |
| 539 | + | // The pointer arrives in global logical pixels. | |
| 540 | + | input <- ports.PointerMotion{X: 500, Y: 200} | |
| 535 | 541 | if v := command(t, commands); v != (ports.PointerFocus{ID: 1, X: 500, Y: 200}) { | |
| 536 | 542 | t.Fatal(v) | |
| 537 | 543 | } | |
| 538 | 544 | // Cmd+- steps down through the clean scales of 5120x2160: 2 → 5/3. | |
| 539 | 545 | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper, Pressed: true} | |
| 540 | - | s = receive(t, scenes) | |
| 546 | + | s = scene(t, scenes) | |
| 541 | 547 | if s.Scale != 5.0/3 || s.OutputWidth != 3072 || s.OutputHeight != 1296 { | |
| 542 | 548 | t.Fatal(s.Scale, s.OutputWidth, s.OutputHeight) | |
| 543 | 549 | } | |
| 544 | 550 | input <- ports.KeyEvent{Keysym: "minus", Keycode: 12, Mods: ports.ModSuper} | |
| 545 | 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 | 553 | t.Fatal(s.Scale) | |
| 548 | 554 | } | |
| 549 | 555 | // A reload that keeps the config scale keeps the live one too. | |
| 550 | 556 | input <- ports.KeyEvent{Keysym: "equal", Keycode: 13, Mods: ports.ModSuper} | |
| 551 | 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 | 559 | t.Fatal(s.Scale) | |
| 554 | 560 | } | |
| 555 | 561 | cfg.Layout.Gaps = 1 | |
| 556 | 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 | 564 | t.Fatal(s.Scale) | |
| 559 | 565 | } | |
| 560 | 566 | // Changing the config scale wins. | |
| 561 | 567 | cfg.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 1.25}} | |
| 562 | 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 | 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 | 53 | client := make(chan ports.ClientEvent) | |
| 54 | 54 | output := make(chan ports.OutputEvent) | |
| 55 | 55 | commands := make(chan ports.ClientCommand, 16) | |
| 56 | - | scenes := make(chan ports.Scene, 1) | |
| 56 | + | scenes := make(chan []ports.Scene, 1) | |
| 57 | 57 | cfg := ports.Config{} | |
| 58 | 58 | cfg.Keyboard.CmdKey = "super" | |
| 59 | 59 | cfg.Layout.MaxColumns = 2 | |
| @@ -69,13 +69,13 @@ func TestLayerChangedChannels(t *testing.T) { | |||
| 69 | 69 | t.Helper() | |
| 70 | 70 | select { | |
| 71 | 71 | case s := <-scenes: | |
| 72 | - | return s | |
| 72 | + | return s[0] | |
| 73 | 73 | case <-time.After(time.Second): | |
| 74 | 74 | t.Fatal("scene timeout") | |
| 75 | 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 | 79 | recv() | |
| 80 | 80 | client <- ports.WindowMapped{ID: 1} | |
| 81 | 81 | before := recv() | |
M internal/core/layout.go +5 -0
| @@ -120,6 +120,11 @@ type Workspace struct { | |||
| 120 | 120 | MaxColumns int | |
| 121 | 121 | presets []Width | |
| 122 | 122 | 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 | 130 | func (w *Workspace) empty() bool { return len(w.Columns) == 0 } | |
M internal/core/monitor.go +76 -3
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | + | import "slices" | |
| 4 | + | ||
| 3 | 5 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 4 | 6 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| 5 | 7 | // below the last one, and an empty unnamed workspace is removed once left, | |
| @@ -9,6 +11,10 @@ package core | |||
| 9 | 11 | // A hidden one is not numbered and not reachable by up/down: only its | |
| 10 | 12 | // `workspace <name>` bind shows it. | |
| 11 | 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 | 18 | Workspaces []*Workspace | |
| 13 | 19 | Active int | |
| 14 | 20 | hidden []*Workspace | |
| @@ -21,18 +27,84 @@ type Monitor struct { | |||
| 21 | 27 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| 22 | 28 | // Overflow follow the monitor defaults. | |
| 23 | 29 | type NamedWorkspace struct { | |
| 24 | - | Name string | |
| 30 | + | Name string | |
| 31 | + | // Monitor is the home monitor (connector or key); "" for the first one. | |
| 32 | + | Monitor string | |
| 25 | 33 | Hidden bool | |
| 26 | 34 | MaxColumns int | |
| 27 | 35 | Overflow Overflow | |
| 28 | 36 | } | |
| 29 | 37 | ||
| 30 | - | func NewMonitor() *Monitor { | |
| 31 | - | m := &Monitor{} | |
| 38 | + | func NewMonitor() *Monitor { return newMonitor("", "") } | |
| 39 | + | ||
| 40 | + | func newMonitor(name, key string) *Monitor { | |
| 41 | + | m := &Monitor{Name: name, Key: key} | |
| 32 | 42 | m.normalize() | |
| 33 | 43 | 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 | 108 | // Current is the workspace on screen. | |
| 37 | 109 | func (m *Monitor) Current() *Workspace { | |
| 38 | 110 | if m.shown != nil { | |
| @@ -44,6 +116,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 44 | 116 | func (m *Monitor) newWorkspace() *Workspace { | |
| 45 | 117 | w := m.template | |
| 46 | 118 | w.presets = append([]Width(nil), m.template.presets...) | |
| 119 | + | w.Columns, w.home = nil, "" | |
| 47 | 120 | 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 | 65 | // updateSlots applies the config and returns the slots to spawn: new ones, | |
| 66 | 66 | // and empty ones whose command changed. A slot dropped from config keeps its | |
| 67 | - | // window as a normal column. Call before Monitor.SetNamed, so a renamed or | |
| 68 | - | // dropped workspace can still be found. | |
| 67 | + | // window as a normal column. Call before named workspaces are applied, so a | |
| 68 | + | // renamed or dropped workspace can still be found. | |
| 69 | 69 | func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |
| 70 | 70 | want := map[slotKey]slotSpec{} | |
| 71 | 71 | for _, s := range specs { | |
| @@ -75,7 +75,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 75 | 75 | if _, ok := want[key]; ok { | |
| 76 | 76 | continue | |
| 77 | 77 | } | |
| 78 | - | if w := c.ws.byName(key.workspace); w != nil { | |
| 78 | + | if _, w := c.byName(key.workspace); w != nil { | |
| 79 | 79 | w.unslot(key.index) | |
| 80 | 80 | } | |
| 81 | 81 | delete(c.slots, key) | |
| @@ -90,7 +90,7 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 90 | 90 | } | |
| 91 | 91 | if st.width != s.width { | |
| 92 | 92 | 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 | 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 | 122 | if !st.pending() || st.token != token || st.window != 0 { | |
| 123 | 123 | continue | |
| 124 | 124 | } | |
| 125 | - | w := c.ws.byName(key.workspace) | |
| 125 | + | sc, w := c.byName(key.workspace) | |
| 126 | 126 | if w == nil { | |
| 127 | 127 | return false | |
| 128 | 128 | } | |
| 129 | 129 | st.window, st.token = id, "" | |
| 130 | 130 | w.AddSlotWindow(id, key.index, st.width) | |
| 131 | - | c.ws.normalize() | |
| 131 | + | sc.mon.normalize() | |
| 132 | 132 | return true | |
| 133 | 133 | } | |
| 134 | 134 | return false | |
| @@ -141,7 +141,7 @@ func (c *Core) releaseSlots() { | |||
| 141 | 141 | if st.window == 0 { | |
| 142 | 142 | continue | |
| 143 | 143 | } | |
| 144 | - | w := c.ws.byName(key.workspace) | |
| 144 | + | _, w := c.byName(key.workspace) | |
| 145 | 145 | if w == nil || !w.inSlot(st.window, key.index) { | |
| 146 | 146 | st.window = 0 | |
| 147 | 147 | } | |
| @@ -152,7 +152,7 @@ func (c *Core) releaseSlots() { | |||
| 152 | 152 | // still pending for the second show in a row is respawned: its command | |
| 153 | 153 | // failed, exited or handed off to another instance without a window. | |
| 154 | 154 | func (c *Core) refill() []slotKey { | |
| 155 | - | name := c.ws.Current().Name | |
| 155 | + | name := c.cur().mon.Current().Name | |
| 156 | 156 | if name == "" { | |
| 157 | 157 | return nil | |
| 158 | 158 | } | |
M internal/core/slots_test.go +26 -26
| @@ -17,7 +17,7 @@ type slotRig struct { | |||
| 17 | 17 | input chan ports.InputEvent | |
| 18 | 18 | reload chan ports.ConfigChanged | |
| 19 | 19 | spawn chan ports.SpawnRequest | |
| 20 | - | scenes chan ports.Scene | |
| 20 | + | scenes chan []ports.Scene | |
| 21 | 21 | errs chan error | |
| 22 | 22 | cfg ports.Config | |
| 23 | 23 | } | |
| @@ -37,7 +37,7 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 37 | 37 | r := &slotRig{ | |
| 38 | 38 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 39 | 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 | 42 | output := make(chan ports.OutputEvent, 1) | |
| 43 | 43 | commands := make(chan ports.ClientCommand, 1024) | |
| @@ -53,8 +53,8 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig { | |||
| 53 | 53 | for range commands { | |
| 54 | 54 | } | |
| 55 | 55 | }() | |
| 56 | - | output <- ports.OutputMode{Width: 100, Height: 80} | |
| 57 | - | receive(t, r.scenes) | |
| 56 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 57 | + | scene(t, r.scenes) | |
| 58 | 58 | return r | |
| 59 | 59 | } | |
| 60 | 60 | ||
| @@ -86,7 +86,7 @@ func (r *slotRig) press(t *testing.T, key string) ports.Scene { | |||
| 86 | 86 | t.Helper() | |
| 87 | 87 | r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true} | |
| 88 | 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 | 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 | 102 | code, foot = receive(t, r.spawn), receive(t, r.spawn) | |
| 103 | 103 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 104 | 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 | 106 | return code, foot | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | // scene waits for a scene matching ok. | |
| 110 | - | func scene(t *testing.T, ch <-chan ports.Scene, ok func(ports.Scene) bool) ports.Scene { | |
| 109 | + | // sceneMatch waits for a first-output scene matching ok. | |
| 110 | + | func sceneMatch(t *testing.T, ch <-chan []ports.Scene, ok func(ports.Scene) bool) ports.Scene { | |
| 111 | 111 | t.Helper() | |
| 112 | 112 | deadline := time.After(time.Second) | |
| 113 | 113 | for { | |
| 114 | 114 | select { | |
| 115 | 115 | case s := <-ch: | |
| 116 | - | if ok(s) { | |
| 117 | - | return s | |
| 116 | + | if ok(s[0]) { | |
| 117 | + | return s[0] | |
| 118 | 118 | } | |
| 119 | 119 | case <-deadline: | |
| 120 | 120 | t.Fatal("no matching scene") | |
| @@ -143,7 +143,7 @@ func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) { | |||
| 143 | 143 | // foot maps first; code's window still goes left of it. | |
| 144 | 144 | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |
| 145 | 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 | 147 | for _, w := range s.Windows { | |
| 148 | 148 | if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused { | |
| 149 | 149 | t.Fatalf("slot window on screen or focus moved: %+v", s.Windows) | |
| @@ -163,7 +163,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||
| 163 | 163 | r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)} | |
| 164 | 164 | r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)} | |
| 165 | 165 | 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 | 167 | noSpawn(t, r.spawn) // nothing relaunches on its own | |
| 168 | 168 | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 169 | 169 | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt} | |
| @@ -172,7 +172,7 @@ func TestSlotRefilledOnlyWhenShown(t *testing.T) { | |||
| 172 | 172 | t.Fatal(again) | |
| 173 | 173 | } | |
| 174 | 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 | 176 | if got := visible(s); got[4].X != 70 { | |
| 177 | 177 | t.Fatal(got) | |
| 178 | 178 | } | |
| @@ -190,7 +190,7 @@ func TestSlotExtraWindowsAreNormal(t *testing.T) { | |||
| 190 | 190 | // A second window of the same process: the slot is taken, normal rules. | |
| 191 | 191 | r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)} | |
| 192 | 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 | 194 | if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) { | |
| 195 | 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 | 200 | r := startSlots(t) | |
| 201 | 201 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 202 | 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 | 204 | cfg := r.cfg | |
| 205 | 205 | ws := cfg.Workspaces[0] | |
| 206 | 206 | ws.Slots = []ports.SlotConfig{ | |
| @@ -218,18 +218,18 @@ func TestSlotReload(t *testing.T) { | |||
| 218 | 218 | if !slices.Equal(argv, []string{"btop", "kitty"}) { | |
| 219 | 219 | t.Fatal(argv) | |
| 220 | 220 | } | |
| 221 | - | receive(t, r.scenes) | |
| 221 | + | scene(t, r.scenes) | |
| 222 | 222 | noSpawn(t, r.spawn) | |
| 223 | 223 | // The old foot spawn no longer fills slot 2 (its command changed): it | |
| 224 | 224 | // opens as a normal window on workspace 1, on screen. | |
| 225 | 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 | 227 | if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) { | |
| 228 | 228 | t.Fatal(got) | |
| 229 | 229 | } | |
| 230 | 230 | r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true} | |
| 231 | 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 | 233 | if got := visible(s); got[2].W != 50 { | |
| 234 | 234 | t.Fatal(got) | |
| 235 | 235 | } | |
| @@ -238,7 +238,7 @@ func TestSlotReload(t *testing.T) { | |||
| 238 | 238 | ws.Slots = nil | |
| 239 | 239 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 240 | 240 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 241 | - | receive(t, r.scenes) | |
| 241 | + | scene(t, r.scenes) | |
| 242 | 242 | noSpawn(t, r.spawn) | |
| 243 | 243 | } | |
| 244 | 244 | ||
| @@ -271,7 +271,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) { | |||
| 271 | 271 | if v := receive(t, r.spawn); v.Argv[0] != "btop" { | |
| 272 | 272 | t.Fatal(v) | |
| 273 | 273 | } | |
| 274 | - | receive(t, r.scenes) | |
| 274 | + | scene(t, r.scenes) | |
| 275 | 275 | noSpawn(t, r.spawn) | |
| 276 | 276 | } | |
| 277 | 277 | ||
| @@ -282,7 +282,7 @@ func TestSlotNotRefilledByOtherBinds(t *testing.T) { | |||
| 282 | 282 | r.fill(t) | |
| 283 | 283 | r.press(t, "d") | |
| 284 | 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 | 286 | r.sync(t) | |
| 287 | 287 | noSpawn(t, r.spawn) | |
| 288 | 288 | r.press(t, "d") | |
| @@ -299,7 +299,7 @@ func TestSlotOldTokenIsNormal(t *testing.T) { | |||
| 299 | 299 | _, foot := r.fill(t) | |
| 300 | 300 | r.client <- ports.WindowUnmapped{ID: 3} | |
| 301 | 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 | 303 | if got := visible(s); got[9] == (ports.Rect{}) { | |
| 304 | 304 | t.Fatalf("old-token window hidden in the slot: %v", got) | |
| 305 | 305 | } | |
| @@ -311,7 +311,7 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |||
| 311 | 311 | r := startSlots(t) | |
| 312 | 312 | code, foot := receive(t, r.spawn), receive(t, r.spawn) | |
| 313 | 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 | 315 | r.press(t, "d") // foot still starting: not respawned | |
| 316 | 316 | noSpawn(t, r.spawn) | |
| 317 | 317 | r.press(t, "d") | |
| @@ -321,12 +321,12 @@ func TestPendingSlotRespawnedOnSecondShow(t *testing.T) { | |||
| 321 | 321 | t.Fatal(again) | |
| 322 | 322 | } | |
| 323 | 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 | 325 | if got := visible(s); got[7] == (ports.Rect{}) { | |
| 326 | 326 | t.Fatalf("stale-token window not placed as a normal one: %v", got) | |
| 327 | 327 | } | |
| 328 | 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 | 330 | if got := visible(s); got[8].W != 30 { | |
| 331 | 331 | t.Fatalf("respawned window not in its slot: %v", got) | |
| 332 | 332 | } | |
| @@ -339,7 +339,7 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||
| 339 | 339 | r.fill(t) | |
| 340 | 340 | r.press(t, "d") // on dev, focus on code (slot 1) | |
| 341 | 341 | r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true} | |
| 342 | - | receive(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | |
| 342 | + | scene(t, r.scenes) // move-to-workspace-up: code goes to workspace 1 | |
| 343 | 343 | noSpawn(t, r.spawn) | |
| 344 | 344 | r.press(t, "d") // back to workspace 1 | |
| 345 | 345 | r.press(t, "d") // dev again: slot 1 is empty | |
M internal/ports/config.go +5 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | |
| 4 | 4 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | |
| 5 | 5 | // monitor's preferred mode. Off disables the connector. | |
| 6 | - | // The first listed, connected, enabled output is used; without a match, the first | |
| 7 | - | // connected output that is not off. | |
| 6 | + | // Every connected output that is not off is used. Outputs are placed left to | |
| 7 | + | // right in config order, then in connection order. | |
| 8 | 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 9 | 9 | // physical = logical × Scale. | |
| 10 | 10 | // ScaleOnly marks an entry set by output.<name>.scale alone: it does not | |
| @@ -60,6 +60,9 @@ type Config struct { | |||
| 60 | 60 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 61 | 61 | type WorkspaceConfig struct { | |
| 62 | 62 | 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 | 66 | // Slots are the declared columns (workspace.<name>.column.N), by N. | |
| 64 | 67 | Slots []SlotConfig | |
| 65 | 68 | Hidden bool | |
M internal/ports/messages.go +86 -19
| @@ -61,6 +61,8 @@ type LayerSurface struct { | |||
| 61 | 61 | Namespace string | |
| 62 | 62 | // Keyboard is the requested interactivity: 0 none, 1 exclusive, 2 on-demand. | |
| 63 | 63 | Keyboard uint32 | |
| 64 | + | // Output is the connector the surface is on. | |
| 65 | + | Output string | |
| 64 | 66 | } | |
| 65 | 67 | ||
| 66 | 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 | 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 | 108 | type PointerMotion struct { | |
| 107 | 109 | X, Y float64 | |
| 108 | 110 | TimeMsec uint32 | |
| @@ -122,19 +124,36 @@ func (PointerButton) inputEvent() {} | |||
| 122 | 124 | // OutputEvent carries output → core notifications. | |
| 123 | 125 | type OutputEvent interface{ outputEvent() } | |
| 124 | 126 | ||
| 125 | - | // OutputMode carries output → core resolution changes in physical pixels. | |
| 126 | - | // Name is the connector, matched against output.<name>.scale. | |
| 127 | - | type OutputMode struct { | |
| 128 | - | Width, Height int | |
| 129 | - | Name string | |
| 127 | + | // OutputInfo describes a connected display. Sizes are physical pixels. | |
| 128 | + | type OutputInfo struct { | |
| 129 | + | // Name is the connector (e.g. DP-2), matched against output.<name>.* config. | |
| 130 | + | Name string | |
| 131 | + | // 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. | |
| 135 | - | type OutputUsable struct{ Rect Rect } | |
| 153 | + | // OutputRemoved carries output → core an unplugged display. | |
| 154 | + | type OutputRemoved struct{ Name string } | |
| 136 | 155 | ||
| 137 | - | func (OutputUsable) outputEvent() {} | |
| 156 | + | func (OutputRemoved) outputEvent() {} | |
| 138 | 157 | ||
| 139 | 158 | // ConfigChanged carries config → core reloads. | |
| 140 | 159 | type ConfigChanged struct{ Config Config } | |
| @@ -142,23 +161,69 @@ type ConfigChanged struct{ Config Config } | |||
| 142 | 161 | // ClientCommand carries core → wayland commands. | |
| 143 | 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 | 166 | type ConfigureWindow struct { | |
| 147 | 167 | ID WindowID | |
| 148 | 168 | Width, Height int | |
| 149 | 169 | Fullscreen, Activated bool | |
| 170 | + | Output string | |
| 150 | 171 | } | |
| 151 | 172 | ||
| 152 | 173 | func (ConfigureWindow) clientCommand() {} | |
| 153 | 174 | ||
| 154 | - | // SetOutputScale carries core → wayland the output scale and its logical size. | |
| 155 | - | // Window and pointer coordinates in other commands are logical. | |
| 156 | - | type SetOutputScale struct { | |
| 157 | - | Scale float64 | |
| 158 | - | Width, Height int | |
| 175 | + | // OutputPlacement is one output in the global layout. X, Y, Width and | |
| 176 | + | // Height are logical; physical = logical × Scale. | |
| 177 | + | type OutputPlacement struct { | |
| 178 | + | Info OutputInfo | |
| 179 | + | 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 | 228 | // SlotsPending tells wayland whether a slot waits for its window. Only then | |
| 164 | 229 | // does it read the SlotEnv of mapping clients. | |
| @@ -232,9 +297,11 @@ type SpawnRequest struct { | |||
| 232 | 297 | Env []string | |
| 233 | 298 | } | |
| 234 | 299 | ||
| 235 | - | // Scene carries core → renderer immutable snapshots with fresh Windows slices. | |
| 236 | - | // Rects and the output size are logical; the renderer multiplies by Scale. | |
| 300 | + | // Scene carries core → renderer immutable snapshots with fresh Windows slices, | |
| 301 | + | // one per output. Rects and the output size are logical and local to the | |
| 302 | + | // output; the renderer multiplies by Scale. | |
| 237 | 303 | type Scene struct { | |
| 304 | + | Output string | |
| 238 | 305 | Seq uint64 | |
| 239 | 306 | OutputWidth, OutputHeight int | |
| 240 | 307 | Scale float64 | |