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