#21 WIP: feat(xwayland): add built-in Xwayland launcher
- state
- draft
- branch
- feat/xwayland-builtin → main
- opened
- bnema ·
- updated
M .mockery.yml +30 -0
M Makefile +9 -2
M README.md +1 -1
M docs/config.md +9 -3
M docs/desktop.md +12 -1
M docs/dev/headless.md +25 -0
M go.mod +4 -3
M go.sum +8 -6
M internal/adapters/config/config.go +18 -4
M internal/adapters/config/config_test.go +36 -4
M internal/adapters/drm/colorop_match.go +11 -0
A internal/adapters/drm/sdrplane_test.go +30 -0
M internal/adapters/vulkan/capture_test.go +3 -0
A internal/adapters/vulkan/norace_test.go +5 -0
A internal/adapters/vulkan/race_test.go +8 -0
M internal/adapters/vulkan/scene_perf_test.go +6 -0
M internal/adapters/wayland/activation.go +3 -3
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
M internal/adapters/wayland/clipboard.go +5 -0
A internal/adapters/wayland/clipboard_xwm.go +93 -0
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
M internal/adapters/wayland/constraints.go +14 -13
M internal/adapters/wayland/decoration.go +1 -1
M internal/adapters/wayland/dialog.go +1 -1
M internal/adapters/wayland/dmabuf.go +3 -2
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
M internal/adapters/wayland/foreigntoplevel.go +6 -6
M internal/adapters/wayland/frames.go +10 -6
M internal/adapters/wayland/globals.go +11 -1
M internal/adapters/wayland/imagecopy.go +1 -1
M internal/adapters/wayland/inhibit.go +5 -0
M internal/adapters/wayland/outputs.go +72 -1
M internal/adapters/wayland/popup.go +2 -2
M internal/adapters/wayland/scale.go +5 -3
M internal/adapters/wayland/screencast.go +1 -1
M internal/adapters/wayland/seat.go +11 -9
M internal/adapters/wayland/server.go +51 -16
M internal/adapters/wayland/surface.go +15 -2
A internal/adapters/wayland/window.go +112 -0
M internal/adapters/wayland/wire_client_test.go +6 -0
A internal/adapters/wayland/x11override_test.go +107 -0
A internal/adapters/wayland/x11peer_test.go +332 -0
A internal/adapters/wayland/x11window.go +458 -0
A internal/adapters/wayland/x11window_test.go +627 -0
M internal/adapters/wayland/xdg.go +32 -21
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
A internal/adapters/wayland/xwayland.go +75 -0
A internal/adapters/wayland/xwayland_test.go +84 -0
A internal/adapters/wayland/xwaylandgrab.go +90 -0
A internal/adapters/wayland/xwm/atoms.go +118 -0
A internal/adapters/wayland/xwm/configure.go +317 -0
A internal/adapters/wayland/xwm/configure_test.go +591 -0
A internal/adapters/wayland/xwm/cursor.go +109 -0
A internal/adapters/wayland/xwm/cursor_test.go +86 -0
A internal/adapters/wayland/xwm/events.go +303 -0
A internal/adapters/wayland/xwm/fdsource_mock_test.go +129 -0
A internal/adapters/wayland/xwm/fixes_test.go +230 -0
A internal/adapters/wayland/xwm/focus.go +218 -0
A internal/adapters/wayland/xwm/host.go +82 -0
A internal/adapters/wayland/xwm/host_mock_test.go +775 -0
A internal/adapters/wayland/xwm/loop_mock_test.go +180 -0
A internal/adapters/wayland/xwm/mapflow.go +359 -0
A internal/adapters/wayland/xwm/mapflow_test.go +806 -0
A internal/adapters/wayland/xwm/props.go +376 -0
A internal/adapters/wayland/xwm/selection.go +1050 -0
A internal/adapters/wayland/xwm/selection_test.go +801 -0
A internal/adapters/wayland/xwm/selection_xsettings.go +198 -0
A internal/adapters/wayland/xwm/space.go +158 -0
A internal/adapters/wayland/xwm/space_test.go +110 -0
A internal/adapters/wayland/xwm/streams_test.go +197 -0
A internal/adapters/wayland/xwm/table.go +242 -0
A internal/adapters/wayland/xwm/table_test.go +138 -0
A internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +950 -0
A internal/adapters/wayland/xwm/testdata/streams/steady.hex +2150 -0
A internal/adapters/wayland/xwm/unmanaged.go +109 -0
A internal/adapters/wayland/xwm/unmanaged_test.go +247 -0
A internal/adapters/wayland/xwm/xpeer_test.go +655 -0
A internal/adapters/wayland/xwm/xsettings_test.go +244 -0
A internal/adapters/wayland/xwm/xwm.go +509 -0
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
A internal/adapters/xwayland/builtin.go +216 -0
A internal/adapters/xwayland/starter_mock_test.go +110 -0
M internal/adapters/xwayland/xwayland.go +46 -18
M internal/adapters/xwayland/xwayland_test.go +207 -0
M internal/app/app.go +13 -6
M internal/app/cursor.go +18 -0
M internal/app/session.go +2 -1
A internal/app/testdata/x11probe/main.go +692 -0
A internal/app/xwayland_test.go +808 -0
M internal/core/client.go +8 -0
M internal/core/core.go +16 -5
M internal/core/core_test.go +52 -3
M internal/core/datadrag.go +1 -1
M internal/core/fullscreen_left_core_test.go +1 -6
M internal/core/fullscreen_left_test.go +29 -0
M internal/core/layout.go +7 -1
A internal/core/place_test.go +80 -0
M internal/core/rules_test.go +44 -0
M internal/core/scene.go +12 -1
M internal/core/spawn_placement.go +9 -1
| @@ -139,7 +139,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 139 | 139 | } | |
| 140 | 140 | } | |
| 141 | 141 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 142 | - | if eff, ok := c.ruleFor(v); ok { | |
| 142 | + | eff, ok := c.ruleFor(v) | |
| 143 | + | // A window mapping at an output's rect goes to that output unless a | |
| 144 | + | // rule names a monitor or workspace. | |
| 145 | + | if v.HasAt && !v.Remap && eff.monitor == "" && eff.workspace == "" && eff.number == 0 { | |
| 146 | + | if i := c.screenAt(float64(v.AtX), float64(v.AtY)); i >= 0 && i != c.focusScreen { | |
| 147 | + | eff.monitor, ok = c.screens[i].name(), true | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if ok { | |
| 143 | 151 | c.placeByRule(v, eff) | |
| 144 | 152 | return | |
| 145 | 153 | } | |
| @@ -139,7 +139,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 139 | } | 139 | } | ||
| 140 | } | 140 | } | ||
| 141 | if s, _ := c.screenOf(v.ID); s == nil { | 141 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 142 | - | if eff, ok := c.ruleFor(v); ok { | 142 | + | eff, ok := c.ruleFor(v) |
| 143 | + | // A window mapping at an output's rect goes to that output unless a | |||
| 144 | + | // rule names a monitor or workspace. | |||
| 145 | + | if v.HasAt && !v.Remap && eff.monitor == "" && eff.workspace == "" && eff.number == 0 { | |||
| 146 | + | if i := c.screenAt(float64(v.AtX), float64(v.AtY)); i >= 0 && i != c.focusScreen { | |||
| 147 | + | eff.monitor, ok = c.screens[i].name(), true | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if ok { | |||
| 143 | c.placeByRule(v, eff) | 151 | c.placeByRule(v, eff) | ||
| 144 | return | 152 | return | ||
| 145 | } | 153 | } | ||
M internal/core/transition.go +20 -0
| @@ -400,11 +400,17 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time | |||
| 400 | 400 | // client is configured once, to its final size. | |
| 401 | 401 | func (c *Core) mapWindow(v ports.WindowMapped) { | |
| 402 | 402 | now := c.now() | |
| 403 | + | if v.Fullscreen && v.Parent == 0 { | |
| 404 | + | // Mapped fullscreen: a tile of its own, whatever size class the | |
| 405 | + | // client reported (a fixed-size game is not a float). | |
| 406 | + | v.Floating, v.Width, v.Height = false, 0, 0 | |
| 407 | + | } | |
| 403 | 408 | c.windows.mapped(v, now) | |
| 404 | 409 | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | |
| 405 | 410 | // Mapped before (a re-map keeps its place), animations off or a | |
| 406 | 411 | // session lock: nothing to animate. | |
| 407 | 412 | c.placement.place(c, v) | |
| 413 | + | c.initialFullscreen(v) | |
| 408 | 414 | return | |
| 409 | 415 | } | |
| 410 | 416 | shots := c.snapshot(now) | |
| @@ -416,10 +422,23 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||
| 416 | 422 | } | |
| 417 | 423 | } | |
| 418 | 424 | c.placement.place(c, v) | |
| 425 | + | c.initialFullscreen(v) | |
| 419 | 426 | c.transition(shots, now) | |
| 420 | 427 | c.appearMapped(v.ID, now) | |
| 421 | 428 | } | |
| 422 | 429 | ||
| 430 | + | // initialFullscreen makes a window that mapped fullscreen cover its screen. | |
| 431 | + | // The fullscreen grace does not apply: it is the state the client opened | |
| 432 | + | // with, not a later request. A re-map (screen already known) is left alone. | |
| 433 | + | func (c *Core) initialFullscreen(v ports.WindowMapped) { | |
| 434 | + | if !v.Fullscreen || v.Remap { | |
| 435 | + | return | |
| 436 | + | } | |
| 437 | + | if s, _ := c.screenOf(v.ID); s != nil { | |
| 438 | + | s.mon.SetFullscreen(v.ID, true) | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 423 | 442 | // appearMapped starts the entrance of a window that just mapped when it | |
| 424 | 443 | // shows on its screen's current workspace as a tile or an ordinary float: | |
| 425 | 444 | // not with a preview open or a session lock, not fullscreen (it keeps | |
| @@ -453,6 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||
| 453 | 472 | // The window is unmapped: a toplevel that maps again keeps its ID and | |
| 454 | 473 | // must be configured from nothing (a leaving entry keeps no configure). | |
| 455 | 474 | c.configures.forget(v.ID) | |
| 475 | + | delete(c.placed, v.ID) | |
| 456 | 476 | sc, w := c.screenOf(v.ID) | |
| 457 | 477 | if sc == nil { | |
| 458 | 478 | return | |
| @@ -400,11 +400,17 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time | |||||
| 400 | // client is configured once, to its final size. | 400 | // client is configured once, to its final size. | ||
| 401 | func (c *Core) mapWindow(v ports.WindowMapped) { | 401 | func (c *Core) mapWindow(v ports.WindowMapped) { | ||
| 402 | now := c.now() | 402 | now := c.now() | ||
| 403 | + | if v.Fullscreen && v.Parent == 0 { | |||
| 404 | + | // Mapped fullscreen: a tile of its own, whatever size class the | |||
| 405 | + | // client reported (a fixed-size game is not a float). | |||
| 406 | + | v.Floating, v.Width, v.Height = false, 0, 0 | |||
| 407 | + | } | |||
| 403 | c.windows.mapped(v, now) | 408 | c.windows.mapped(v, now) | ||
| 404 | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | 409 | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | ||
| 405 | // Mapped before (a re-map keeps its place), animations off or a | 410 | // Mapped before (a re-map keeps its place), animations off or a | ||
| 406 | // session lock: nothing to animate. | 411 | // session lock: nothing to animate. | ||
| 407 | c.placement.place(c, v) | 412 | c.placement.place(c, v) | ||
| 413 | + | c.initialFullscreen(v) | |||
| 408 | return | 414 | return | ||
| 409 | } | 415 | } | ||
| 410 | shots := c.snapshot(now) | 416 | shots := c.snapshot(now) | ||
| @@ -416,10 +422,23 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||||
| 416 | } | 422 | } | ||
| 417 | } | 423 | } | ||
| 418 | c.placement.place(c, v) | 424 | c.placement.place(c, v) | ||
| 425 | + | c.initialFullscreen(v) | |||
| 419 | c.transition(shots, now) | 426 | c.transition(shots, now) | ||
| 420 | c.appearMapped(v.ID, now) | 427 | c.appearMapped(v.ID, now) | ||
| 421 | } | 428 | } | ||
| 422 | 429 | ||||
| 430 | + | // initialFullscreen makes a window that mapped fullscreen cover its screen. | |||
| 431 | + | // The fullscreen grace does not apply: it is the state the client opened | |||
| 432 | + | // with, not a later request. A re-map (screen already known) is left alone. | |||
| 433 | + | func (c *Core) initialFullscreen(v ports.WindowMapped) { | |||
| 434 | + | if !v.Fullscreen || v.Remap { | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 437 | + | if s, _ := c.screenOf(v.ID); s != nil { | |||
| 438 | + | s.mon.SetFullscreen(v.ID, true) | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 423 | // appearMapped starts the entrance of a window that just mapped when it | 442 | // appearMapped starts the entrance of a window that just mapped when it | ||
| 424 | // shows on its screen's current workspace as a tile or an ordinary float: | 443 | // shows on its screen's current workspace as a tile or an ordinary float: | ||
| 425 | // not with a preview open or a session lock, not fullscreen (it keeps | 444 | // not with a preview open or a session lock, not fullscreen (it keeps | ||
| @@ -453,6 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||||
| 453 | // The window is unmapped: a toplevel that maps again keeps its ID and | 472 | // The window is unmapped: a toplevel that maps again keeps its ID and | ||
| 454 | // must be configured from nothing (a leaving entry keeps no configure). | 473 | // must be configured from nothing (a leaving entry keeps no configure). | ||
| 455 | c.configures.forget(v.ID) | 474 | c.configures.forget(v.ID) | ||
| 475 | + | delete(c.placed, v.ID) | |||
| 456 | sc, w := c.screenOf(v.ID) | 476 | sc, w := c.screenOf(v.ID) | ||
| 457 | if sc == nil { | 477 | if sc == nil { | ||
| 458 | return | 478 | return | ||
A internal/core/unmanaged.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "slices" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // unmanagedState is a mapped override-redirect X11 window: an absolute layout | |
| 11 | + | // rect chosen by the client, no parent, never keyboard-focused. rect is in | |
| 12 | + | // global logical coordinates. | |
| 13 | + | type unmanagedState struct { | |
| 14 | + | id WindowID | |
| 15 | + | rect Rect | |
| 16 | + | noInput bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // unmanagedEvent applies an unmanaged window event. changed is true when the | |
| 20 | + | // scene or the pointer target may differ. | |
| 21 | + | func (c *Core) unmanagedEvent(ctx context.Context, ev ports.ClientEvent) (changed bool, err error) { | |
| 22 | + | switch v := ev.(type) { | |
| 23 | + | case ports.UnmanagedMapped: | |
| 24 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |
| 25 | + | if u := c.unmanaged[v.ID]; u != nil { | |
| 26 | + | if u.rect == r && u.noInput == v.NoInput { | |
| 27 | + | return false, nil | |
| 28 | + | } | |
| 29 | + | u.rect, u.noInput = r, v.NoInput | |
| 30 | + | } else { | |
| 31 | + | c.unmanaged[v.ID] = &unmanagedState{id: v.ID, rect: r, noInput: v.NoInput} | |
| 32 | + | c.unmanagedOrder = append(c.unmanagedOrder, v.ID) | |
| 33 | + | } | |
| 34 | + | case ports.UnmanagedMoved: | |
| 35 | + | u := c.unmanaged[v.ID] | |
| 36 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |
| 37 | + | if u == nil || u.rect == r { | |
| 38 | + | return false, nil | |
| 39 | + | } | |
| 40 | + | u.rect = r | |
| 41 | + | case ports.UnmanagedUnmapped: | |
| 42 | + | if c.unmanaged[v.ID] == nil { | |
| 43 | + | return false, nil | |
| 44 | + | } | |
| 45 | + | delete(c.unmanaged, v.ID) | |
| 46 | + | c.unmanagedOrder = slices.DeleteFunc(c.unmanagedOrder, func(id WindowID) bool { return id == v.ID }) | |
| 47 | + | c.windows.drop(v.ID) | |
| 48 | + | if err := c.dataDragGone(ctx, v.ID); err != nil { | |
| 49 | + | return false, err | |
| 50 | + | } | |
| 51 | + | // rehit does nothing while a button grab or drag is held: do not | |
| 52 | + | // leave the pointer and grab on the dead window. | |
| 53 | + | if c.grab == v.ID { | |
| 54 | + | c.grab = 0 | |
| 55 | + | } | |
| 56 | + | if c.pointer == v.ID { | |
| 57 | + | c.pointer = 0 | |
| 58 | + | if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil { | |
| 59 | + | return false, err | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | // The window under a still pointer may have changed. | |
| 64 | + | return true, c.rehit(ctx) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // unmanagedScreen is the screen showing u: the one containing its origin, or | |
| 68 | + | // the one the pointer clamp would pick when the origin is in a hole. | |
| 69 | + | func (c *Core) unmanagedScreen(u *unmanagedState) *screen { | |
| 70 | + | x, y := float64(u.rect.X), float64(u.rect.Y) | |
| 71 | + | i := c.screenAt(x, y) | |
| 72 | + | if i < 0 { | |
| 73 | + | cx, cy := c.clampPointer(x, y, x, y) | |
| 74 | + | i = c.screenAt(cx, cy) | |
| 75 | + | } | |
| 76 | + | if i < 0 { | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | return c.screens[i] | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // sceneUnmanaged appends the unmanaged windows shown on sc, bottom to top, | |
| 83 | + | // drawn as popups over the layers. They are hidden while sc previews. | |
| 84 | + | func (c *Core) sceneUnmanaged(out []ports.SceneWindow, sc *screen) []ports.SceneWindow { | |
| 85 | + | if len(c.unmanagedOrder) == 0 || sc.mon.previewing() { | |
| 86 | + | return out | |
| 87 | + | } | |
| 88 | + | for _, id := range c.unmanagedOrder { | |
| 89 | + | u := c.unmanaged[id] | |
| 90 | + | if u == nil || c.unmanagedScreen(u) != sc { | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | r := u.rect | |
| 94 | + | r.X, r.Y = r.X-sc.x, r.Y-sc.y | |
| 95 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: true}) | |
| 96 | + | } | |
| 97 | + | return out | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // unmanagedAt is the topmost unmanaged window taking the pointer at the | |
| 101 | + | // screen-local point. None while the pointer is locked or the screen previews. | |
| 102 | + | func (c *Core) unmanagedAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |
| 103 | + | if len(c.unmanagedOrder) == 0 || c.constraint.Mode == ports.ConstraintLock || sc.mon.previewing() { | |
| 104 | + | return 0, 0, 0 | |
| 105 | + | } | |
| 106 | + | for i := len(c.unmanagedOrder) - 1; i >= 0; i-- { | |
| 107 | + | u := c.unmanaged[c.unmanagedOrder[i]] | |
| 108 | + | if u == nil || u.noInput || c.unmanagedScreen(u) != sc { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | x, y := lx-float64(u.rect.X-sc.x), ly-float64(u.rect.Y-sc.y) | |
| 112 | + | if x >= 0 && x < float64(u.rect.W) && y >= 0 && y < float64(u.rect.H) && c.acceptsInput(u.id, x, y) { | |
| 113 | + | return u.id, x, y | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return 0, 0, 0 | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // unmanagedState is a mapped override-redirect X11 window: an absolute layout | |||
| 11 | + | // rect chosen by the client, no parent, never keyboard-focused. rect is in | |||
| 12 | + | // global logical coordinates. | |||
| 13 | + | type unmanagedState struct { | |||
| 14 | + | id WindowID | |||
| 15 | + | rect Rect | |||
| 16 | + | noInput bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // unmanagedEvent applies an unmanaged window event. changed is true when the | |||
| 20 | + | // scene or the pointer target may differ. | |||
| 21 | + | func (c *Core) unmanagedEvent(ctx context.Context, ev ports.ClientEvent) (changed bool, err error) { | |||
| 22 | + | switch v := ev.(type) { | |||
| 23 | + | case ports.UnmanagedMapped: | |||
| 24 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |||
| 25 | + | if u := c.unmanaged[v.ID]; u != nil { | |||
| 26 | + | if u.rect == r && u.noInput == v.NoInput { | |||
| 27 | + | return false, nil | |||
| 28 | + | } | |||
| 29 | + | u.rect, u.noInput = r, v.NoInput | |||
| 30 | + | } else { | |||
| 31 | + | c.unmanaged[v.ID] = &unmanagedState{id: v.ID, rect: r, noInput: v.NoInput} | |||
| 32 | + | c.unmanagedOrder = append(c.unmanagedOrder, v.ID) | |||
| 33 | + | } | |||
| 34 | + | case ports.UnmanagedMoved: | |||
| 35 | + | u := c.unmanaged[v.ID] | |||
| 36 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |||
| 37 | + | if u == nil || u.rect == r { | |||
| 38 | + | return false, nil | |||
| 39 | + | } | |||
| 40 | + | u.rect = r | |||
| 41 | + | case ports.UnmanagedUnmapped: | |||
| 42 | + | if c.unmanaged[v.ID] == nil { | |||
| 43 | + | return false, nil | |||
| 44 | + | } | |||
| 45 | + | delete(c.unmanaged, v.ID) | |||
| 46 | + | c.unmanagedOrder = slices.DeleteFunc(c.unmanagedOrder, func(id WindowID) bool { return id == v.ID }) | |||
| 47 | + | c.windows.drop(v.ID) | |||
| 48 | + | if err := c.dataDragGone(ctx, v.ID); err != nil { | |||
| 49 | + | return false, err | |||
| 50 | + | } | |||
| 51 | + | // rehit does nothing while a button grab or drag is held: do not | |||
| 52 | + | // leave the pointer and grab on the dead window. | |||
| 53 | + | if c.grab == v.ID { | |||
| 54 | + | c.grab = 0 | |||
| 55 | + | } | |||
| 56 | + | if c.pointer == v.ID { | |||
| 57 | + | c.pointer = 0 | |||
| 58 | + | if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil { | |||
| 59 | + | return false, err | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | // The window under a still pointer may have changed. | |||
| 64 | + | return true, c.rehit(ctx) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // unmanagedScreen is the screen showing u: the one containing its origin, or | |||
| 68 | + | // the one the pointer clamp would pick when the origin is in a hole. | |||
| 69 | + | func (c *Core) unmanagedScreen(u *unmanagedState) *screen { | |||
| 70 | + | x, y := float64(u.rect.X), float64(u.rect.Y) | |||
| 71 | + | i := c.screenAt(x, y) | |||
| 72 | + | if i < 0 { | |||
| 73 | + | cx, cy := c.clampPointer(x, y, x, y) | |||
| 74 | + | i = c.screenAt(cx, cy) | |||
| 75 | + | } | |||
| 76 | + | if i < 0 { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | return c.screens[i] | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // sceneUnmanaged appends the unmanaged windows shown on sc, bottom to top, | |||
| 83 | + | // drawn as popups over the layers. They are hidden while sc previews. | |||
| 84 | + | func (c *Core) sceneUnmanaged(out []ports.SceneWindow, sc *screen) []ports.SceneWindow { | |||
| 85 | + | if len(c.unmanagedOrder) == 0 || sc.mon.previewing() { | |||
| 86 | + | return out | |||
| 87 | + | } | |||
| 88 | + | for _, id := range c.unmanagedOrder { | |||
| 89 | + | u := c.unmanaged[id] | |||
| 90 | + | if u == nil || c.unmanagedScreen(u) != sc { | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 93 | + | r := u.rect | |||
| 94 | + | r.X, r.Y = r.X-sc.x, r.Y-sc.y | |||
| 95 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: true}) | |||
| 96 | + | } | |||
| 97 | + | return out | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // unmanagedAt is the topmost unmanaged window taking the pointer at the | |||
| 101 | + | // screen-local point. None while the pointer is locked or the screen previews. | |||
| 102 | + | func (c *Core) unmanagedAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |||
| 103 | + | if len(c.unmanagedOrder) == 0 || c.constraint.Mode == ports.ConstraintLock || sc.mon.previewing() { | |||
| 104 | + | return 0, 0, 0 | |||
| 105 | + | } | |||
| 106 | + | for i := len(c.unmanagedOrder) - 1; i >= 0; i-- { | |||
| 107 | + | u := c.unmanaged[c.unmanagedOrder[i]] | |||
| 108 | + | if u == nil || u.noInput || c.unmanagedScreen(u) != sc { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | x, y := lx-float64(u.rect.X-sc.x), ly-float64(u.rect.Y-sc.y) | |||
| 112 | + | if x >= 0 && x < float64(u.rect.W) && y >= 0 && y < float64(u.rect.H) && c.acceptsInput(u.id, x, y) { | |||
| 113 | + | return u.id, x, y | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return 0, 0, 0 | |||
| 117 | + | } | |||
A internal/core/unmanaged_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func unmanagedScene(t *testing.T, c *Core, i int) ports.Scene { | |
| 11 | + | t.Helper() | |
| 12 | + | s, err := c.sceneFor(context.Background(), i, c.screens[i], nil, nil) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | return s | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func mapUnmanaged(t *testing.T, c *Core, id WindowID, x, y, w, h int, noInput bool) { | |
| 20 | + | t.Helper() | |
| 21 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: id, X: x, Y: y, Width: w, Height: h, NoInput: noInput}) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestUnmanagedScene(t *testing.T) { | |
| 25 | + | c := publishRig(t, 2) | |
| 26 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |
| 27 | + | mapUnmanaged(t, c, 101, 5, 5, 10, 10, true) | |
| 28 | + | b := unmanagedScene(t, c, 1) | |
| 29 | + | last := b.Windows[len(b.Windows)-1] | |
| 30 | + | if last.ID != 100 || last.Rect != (Rect{X: 10, Y: 20, W: 50, H: 40}) || !last.Popup || !last.OverLayers { | |
| 31 | + | t.Fatalf("screen B top window %+v", last) | |
| 32 | + | } | |
| 33 | + | a := unmanagedScene(t, c, 0) | |
| 34 | + | if got := a.Windows[len(a.Windows)-1]; got.ID != 101 || got.Rect.X != 5 { | |
| 35 | + | t.Fatalf("screen A top window %+v", got) | |
| 36 | + | } | |
| 37 | + | c.clientEvent(context.Background(), ports.UnmanagedMoved{ID: 100, X: 20, Y: 20, Width: 50, Height: 40}) | |
| 38 | + | if got := unmanagedScene(t, c, 0).Windows; got[len(got)-2].ID != 100 || got[len(got)-2].Rect.X != 20 { | |
| 39 | + | t.Fatalf("moved window not on screen A: %+v", got) | |
| 40 | + | } | |
| 41 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |
| 42 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 101}) | |
| 43 | + | if len(c.unmanagedOrder) != 0 || len(c.unmanaged) != 0 { | |
| 44 | + | t.Fatal("state left after unmap") | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestUnmanagedHiddenWhilePreviewing(t *testing.T) { | |
| 49 | + | c := publishRig(t, 1) | |
| 50 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |
| 51 | + | c.cur().mon.ov.open = true | |
| 52 | + | if !c.cur().mon.previewing() { | |
| 53 | + | t.Fatal("setup: not previewing") | |
| 54 | + | } | |
| 55 | + | for _, w := range unmanagedScene(t, c, 0).Windows { | |
| 56 | + | if w.ID == 100 { | |
| 57 | + | t.Fatal("unmanaged window drawn in the overview") | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if id, _, _ := c.hit(20, 20); id == 100 { | |
| 61 | + | t.Fatal("unmanaged window hit in the overview") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestUnmanagedHit(t *testing.T) { | |
| 66 | + | c := publishRig(t, 2) | |
| 67 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |
| 68 | + | mapUnmanaged(t, c, 101, 320, 30, 10, 10, false) | |
| 69 | + | mapUnmanaged(t, c, 102, 340, 30, 10, 10, true) | |
| 70 | + | if id, x, y := c.hit(325, 35); id != 101 || x != 5 || y != 5 { | |
| 71 | + | t.Fatalf("topmost: %d %v %v", id, x, y) | |
| 72 | + | } | |
| 73 | + | if id, _, _ := c.hit(345, 35); id != 100 { | |
| 74 | + | t.Fatalf("noInput must be skipped: %d", id) | |
| 75 | + | } | |
| 76 | + | c.windows.setRegion(ports.InputRegionChanged{ID: 100, Rects: []Rect{{X: 0, Y: 0, W: 5, H: 5}}}) | |
| 77 | + | if id, _, _ := c.hit(345, 35); id == 100 { | |
| 78 | + | t.Fatal("outside the input region") | |
| 79 | + | } | |
| 80 | + | c.constraint.Mode = ports.ConstraintLock | |
| 81 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |
| 82 | + | t.Fatal("locked pointer must not move to an unmanaged window") | |
| 83 | + | } | |
| 84 | + | c.constraint.Mode = 0 | |
| 85 | + | c.security.Protected = true | |
| 86 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |
| 87 | + | t.Fatal("protected session hit an unmanaged window") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func unmanagedRig(t testing.TB, n int) *Core { | |
| 92 | + | c := publishRig(t, 1) | |
| 93 | + | for i := range n { | |
| 94 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: WindowID(100 + i), X: 10 * i, Y: 10, Width: 30, Height: 30}) | |
| 95 | + | } | |
| 96 | + | return c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func publishOnce(t testing.TB, c *Core) { | |
| 100 | + | if err := c.publish(context.Background()); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | <-c.ch.Scenes | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // TestUnmanagedPublishAllocations: three unmanaged windows cost no more than | |
| 107 | + | // none (the scene window slice is presized for them). | |
| 108 | + | func TestUnmanagedPublishAllocations(t *testing.T) { | |
| 109 | + | base, with := unmanagedRig(t, 0), unmanagedRig(t, 3) | |
| 110 | + | publishOnce(t, base) | |
| 111 | + | publishOnce(t, with) | |
| 112 | + | a := testing.AllocsPerRun(50, func() { publishOnce(t, base) }) | |
| 113 | + | b := testing.AllocsPerRun(50, func() { publishOnce(t, with) }) | |
| 114 | + | if b > a { | |
| 115 | + | t.Fatalf("publish allocs with unmanaged = %v, without = %v", b, a) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestUnmanagedHitAllocations(t *testing.T) { | |
| 120 | + | c := unmanagedRig(t, 3) | |
| 121 | + | if n := testing.AllocsPerRun(100, func() { c.hit(25, 20); c.hit(250, 150) }); n != 0 { | |
| 122 | + | t.Fatalf("hit allocs = %v", n) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestUnmanagedUnmapDuringGrabClearsPointer(t *testing.T) { | |
| 127 | + | c := publishRig(t, 1) | |
| 128 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |
| 129 | + | cmds := make(chan ports.ClientCommand, 64) | |
| 130 | + | c.ch.Commands = cmds | |
| 131 | + | c.pointer, c.grab = 100, 100 | |
| 132 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |
| 133 | + | if c.pointer == 100 || c.grab != 0 { | |
| 134 | + | t.Fatalf("pointer %d grab %d left on the dead window", c.pointer, c.grab) | |
| 135 | + | } | |
| 136 | + | var focus bool | |
| 137 | + | for len(cmds) > 0 { | |
| 138 | + | if f, ok := (<-cmds).(ports.PointerFocus); ok && f == (ports.PointerFocus{}) { | |
| 139 | + | focus = true | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | if !focus { | |
| 143 | + | t.Fatal("pointer focus not reset") | |
| 144 | + | } | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func unmanagedScene(t *testing.T, c *Core, i int) ports.Scene { | |||
| 11 | + | t.Helper() | |||
| 12 | + | s, err := c.sceneFor(context.Background(), i, c.screens[i], nil, nil) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | return s | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func mapUnmanaged(t *testing.T, c *Core, id WindowID, x, y, w, h int, noInput bool) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: id, X: x, Y: y, Width: w, Height: h, NoInput: noInput}) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestUnmanagedScene(t *testing.T) { | |||
| 25 | + | c := publishRig(t, 2) | |||
| 26 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |||
| 27 | + | mapUnmanaged(t, c, 101, 5, 5, 10, 10, true) | |||
| 28 | + | b := unmanagedScene(t, c, 1) | |||
| 29 | + | last := b.Windows[len(b.Windows)-1] | |||
| 30 | + | if last.ID != 100 || last.Rect != (Rect{X: 10, Y: 20, W: 50, H: 40}) || !last.Popup || !last.OverLayers { | |||
| 31 | + | t.Fatalf("screen B top window %+v", last) | |||
| 32 | + | } | |||
| 33 | + | a := unmanagedScene(t, c, 0) | |||
| 34 | + | if got := a.Windows[len(a.Windows)-1]; got.ID != 101 || got.Rect.X != 5 { | |||
| 35 | + | t.Fatalf("screen A top window %+v", got) | |||
| 36 | + | } | |||
| 37 | + | c.clientEvent(context.Background(), ports.UnmanagedMoved{ID: 100, X: 20, Y: 20, Width: 50, Height: 40}) | |||
| 38 | + | if got := unmanagedScene(t, c, 0).Windows; got[len(got)-2].ID != 100 || got[len(got)-2].Rect.X != 20 { | |||
| 39 | + | t.Fatalf("moved window not on screen A: %+v", got) | |||
| 40 | + | } | |||
| 41 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |||
| 42 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 101}) | |||
| 43 | + | if len(c.unmanagedOrder) != 0 || len(c.unmanaged) != 0 { | |||
| 44 | + | t.Fatal("state left after unmap") | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestUnmanagedHiddenWhilePreviewing(t *testing.T) { | |||
| 49 | + | c := publishRig(t, 1) | |||
| 50 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |||
| 51 | + | c.cur().mon.ov.open = true | |||
| 52 | + | if !c.cur().mon.previewing() { | |||
| 53 | + | t.Fatal("setup: not previewing") | |||
| 54 | + | } | |||
| 55 | + | for _, w := range unmanagedScene(t, c, 0).Windows { | |||
| 56 | + | if w.ID == 100 { | |||
| 57 | + | t.Fatal("unmanaged window drawn in the overview") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if id, _, _ := c.hit(20, 20); id == 100 { | |||
| 61 | + | t.Fatal("unmanaged window hit in the overview") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestUnmanagedHit(t *testing.T) { | |||
| 66 | + | c := publishRig(t, 2) | |||
| 67 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |||
| 68 | + | mapUnmanaged(t, c, 101, 320, 30, 10, 10, false) | |||
| 69 | + | mapUnmanaged(t, c, 102, 340, 30, 10, 10, true) | |||
| 70 | + | if id, x, y := c.hit(325, 35); id != 101 || x != 5 || y != 5 { | |||
| 71 | + | t.Fatalf("topmost: %d %v %v", id, x, y) | |||
| 72 | + | } | |||
| 73 | + | if id, _, _ := c.hit(345, 35); id != 100 { | |||
| 74 | + | t.Fatalf("noInput must be skipped: %d", id) | |||
| 75 | + | } | |||
| 76 | + | c.windows.setRegion(ports.InputRegionChanged{ID: 100, Rects: []Rect{{X: 0, Y: 0, W: 5, H: 5}}}) | |||
| 77 | + | if id, _, _ := c.hit(345, 35); id == 100 { | |||
| 78 | + | t.Fatal("outside the input region") | |||
| 79 | + | } | |||
| 80 | + | c.constraint.Mode = ports.ConstraintLock | |||
| 81 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |||
| 82 | + | t.Fatal("locked pointer must not move to an unmanaged window") | |||
| 83 | + | } | |||
| 84 | + | c.constraint.Mode = 0 | |||
| 85 | + | c.security.Protected = true | |||
| 86 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |||
| 87 | + | t.Fatal("protected session hit an unmanaged window") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func unmanagedRig(t testing.TB, n int) *Core { | |||
| 92 | + | c := publishRig(t, 1) | |||
| 93 | + | for i := range n { | |||
| 94 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: WindowID(100 + i), X: 10 * i, Y: 10, Width: 30, Height: 30}) | |||
| 95 | + | } | |||
| 96 | + | return c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func publishOnce(t testing.TB, c *Core) { | |||
| 100 | + | if err := c.publish(context.Background()); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | <-c.ch.Scenes | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // TestUnmanagedPublishAllocations: three unmanaged windows cost no more than | |||
| 107 | + | // none (the scene window slice is presized for them). | |||
| 108 | + | func TestUnmanagedPublishAllocations(t *testing.T) { | |||
| 109 | + | base, with := unmanagedRig(t, 0), unmanagedRig(t, 3) | |||
| 110 | + | publishOnce(t, base) | |||
| 111 | + | publishOnce(t, with) | |||
| 112 | + | a := testing.AllocsPerRun(50, func() { publishOnce(t, base) }) | |||
| 113 | + | b := testing.AllocsPerRun(50, func() { publishOnce(t, with) }) | |||
| 114 | + | if b > a { | |||
| 115 | + | t.Fatalf("publish allocs with unmanaged = %v, without = %v", b, a) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestUnmanagedHitAllocations(t *testing.T) { | |||
| 120 | + | c := unmanagedRig(t, 3) | |||
| 121 | + | if n := testing.AllocsPerRun(100, func() { c.hit(25, 20); c.hit(250, 150) }); n != 0 { | |||
| 122 | + | t.Fatalf("hit allocs = %v", n) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestUnmanagedUnmapDuringGrabClearsPointer(t *testing.T) { | |||
| 127 | + | c := publishRig(t, 1) | |||
| 128 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |||
| 129 | + | cmds := make(chan ports.ClientCommand, 64) | |||
| 130 | + | c.ch.Commands = cmds | |||
| 131 | + | c.pointer, c.grab = 100, 100 | |||
| 132 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |||
| 133 | + | if c.pointer == 100 || c.grab != 0 { | |||
| 134 | + | t.Fatalf("pointer %d grab %d left on the dead window", c.pointer, c.grab) | |||
| 135 | + | } | |||
| 136 | + | var focus bool | |||
| 137 | + | for len(cmds) > 0 { | |||
| 138 | + | if f, ok := (<-cmds).(ports.PointerFocus); ok && f == (ports.PointerFocus{}) { | |||
| 139 | + | focus = true | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | if !focus { | |||
| 143 | + | t.Fatal("pointer focus not reset") | |||
| 144 | + | } | |||
| 145 | + | } | |||
M internal/ports/client_commands.go +10 -0
| @@ -47,6 +47,16 @@ type ConfigureWindow struct { | |||
| 47 | 47 | ||
| 48 | 48 | func (ConfigureWindow) clientCommand() {} | |
| 49 | 49 | ||
| 50 | + | // PlaceWindow carries core → wayland the position of a Positioned window's | |
| 51 | + | // client rect, in layout logical coordinates (output origin included). It | |
| 52 | + | // follows the ConfigureWindow of a frame and is sent once per change. | |
| 53 | + | type PlaceWindow struct { | |
| 54 | + | ID WindowID | |
| 55 | + | X, Y int | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (PlaceWindow) clientCommand() {} | |
| 59 | + | ||
| 50 | 60 | // OutputPlacement is one output in the global layout. X, Y, Width and | |
| 51 | 61 | // Height are logical (after Transform: they swap for 90/270); | |
| 52 | 62 | // physical = logical × Scale. Info.Width and Info.Height are the physical | |
| @@ -47,6 +47,16 @@ type ConfigureWindow struct { | |||||
| 47 | 47 | ||||
| 48 | func (ConfigureWindow) clientCommand() {} | 48 | func (ConfigureWindow) clientCommand() {} | ||
| 49 | 49 | ||||
| 50 | + | // PlaceWindow carries core → wayland the position of a Positioned window's | |||
| 51 | + | // client rect, in layout logical coordinates (output origin included). It | |||
| 52 | + | // follows the ConfigureWindow of a frame and is sent once per change. | |||
| 53 | + | type PlaceWindow struct { | |||
| 54 | + | ID WindowID | |||
| 55 | + | X, Y int | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (PlaceWindow) clientCommand() {} | |||
| 59 | + | ||||
| 50 | // OutputPlacement is one output in the global layout. X, Y, Width and | 60 | // OutputPlacement is one output in the global layout. X, Y, Width and | ||
| 51 | // Height are logical (after Transform: they swap for 90/270); | 61 | // Height are logical (after Transform: they swap for 90/270); | ||
| 52 | // physical = logical × Scale. Info.Width and Info.Height are the physical | 62 | // physical = logical × Scale. Info.Width and Info.Height are the physical | ||
M internal/ports/client_events.go +37 -0
| @@ -20,12 +20,24 @@ type WindowMapped struct { | |||
| 20 | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | |
| 21 | 21 | // it. | |
| 22 | 22 | Parent WindowID | |
| 23 | + | // Positioned windows are placed by core: it sends PlaceWindow with the | |
| 24 | + | // client rect origin. Set only by the X11 host, false for xdg. | |
| 25 | + | Positioned bool | |
| 23 | 26 | // Remap is set when the toplevel mapped before and was unmapped (a tray | |
| 24 | 27 | // app restored): window rules only place its first map. | |
| 25 | 28 | Remap bool | |
| 26 | 29 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | |
| 27 | 30 | // focusing the parent focuses the dialog. | |
| 28 | 31 | Modal bool | |
| 32 | + | // Fullscreen is set when the client asked for fullscreen before mapping | |
| 33 | + | // (X11 _NET_WM_STATE_FULLSCREEN): core maps it as a tile covering its | |
| 34 | + | // screen, without the fullscreen grace of later requests. | |
| 35 | + | Fullscreen bool | |
| 36 | + | // HasAt asks for the screen containing the layout point (AtX, AtY) when | |
| 37 | + | // no rule sets a monitor: the X11 host sets it for a window that maps at | |
| 38 | + | // exactly one output's rect. | |
| 39 | + | HasAt bool | |
| 40 | + | AtX, AtY int | |
| 29 | 41 | } | |
| 30 | 42 | ||
| 31 | 43 | // SlotEnv is the environment variable neferwl sets on processes it spawns | |
| @@ -49,6 +61,31 @@ type WindowUnmapped struct{ ID WindowID } | |||
| 49 | 61 | ||
| 50 | 62 | func (WindowUnmapped) clientEvent() {} | |
| 51 | 63 | ||
| 64 | + | // UnmanagedMapped carries wayland → core that an override-redirect X11 window | |
| 65 | + | // shows. The rect is its place in the layout (logical coordinates). NoInput | |
| 66 | + | // windows (tooltips, drag icons, notifications) never take the pointer. | |
| 67 | + | type UnmanagedMapped struct { | |
| 68 | + | ID WindowID | |
| 69 | + | X, Y, Width, Height int | |
| 70 | + | NoInput bool | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (UnmanagedMapped) clientEvent() {} | |
| 74 | + | ||
| 75 | + | // UnmanagedMoved carries wayland → core a move and/or resize of a mapped | |
| 76 | + | // unmanaged window. | |
| 77 | + | type UnmanagedMoved struct { | |
| 78 | + | ID WindowID | |
| 79 | + | X, Y, Width, Height int | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (UnmanagedMoved) clientEvent() {} | |
| 83 | + | ||
| 84 | + | // UnmanagedUnmapped carries wayland → core that an unmanaged window is gone. | |
| 85 | + | type UnmanagedUnmapped struct{ ID WindowID } | |
| 86 | + | ||
| 87 | + | func (UnmanagedUnmapped) clientEvent() {} | |
| 88 | + | ||
| 52 | 89 | // ShortcutsInhibit carries wayland → core that a window asks for (Active) | |
| 53 | 90 | // or stops asking for all keys, compositor binds included, while it has | |
| 54 | 91 | // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1). | |
| @@ -20,12 +20,24 @@ type WindowMapped struct { | |||||
| 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | ||
| 21 | // it. | 21 | // it. | ||
| 22 | Parent WindowID | 22 | Parent WindowID | ||
| 23 | + | // Positioned windows are placed by core: it sends PlaceWindow with the | |||
| 24 | + | // client rect origin. Set only by the X11 host, false for xdg. | |||
| 25 | + | Positioned bool | |||
| 23 | // Remap is set when the toplevel mapped before and was unmapped (a tray | 26 | // Remap is set when the toplevel mapped before and was unmapped (a tray | ||
| 24 | // app restored): window rules only place its first map. | 27 | // app restored): window rules only place its first map. | ||
| 25 | Remap bool | 28 | Remap bool | ||
| 26 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | 29 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | ||
| 27 | // focusing the parent focuses the dialog. | 30 | // focusing the parent focuses the dialog. | ||
| 28 | Modal bool | 31 | Modal bool | ||
| 32 | + | // Fullscreen is set when the client asked for fullscreen before mapping | |||
| 33 | + | // (X11 _NET_WM_STATE_FULLSCREEN): core maps it as a tile covering its | |||
| 34 | + | // screen, without the fullscreen grace of later requests. | |||
| 35 | + | Fullscreen bool | |||
| 36 | + | // HasAt asks for the screen containing the layout point (AtX, AtY) when | |||
| 37 | + | // no rule sets a monitor: the X11 host sets it for a window that maps at | |||
| 38 | + | // exactly one output's rect. | |||
| 39 | + | HasAt bool | |||
| 40 | + | AtX, AtY int | |||
| 29 | } | 41 | } | ||
| 30 | 42 | ||||
| 31 | // SlotEnv is the environment variable neferwl sets on processes it spawns | 43 | // SlotEnv is the environment variable neferwl sets on processes it spawns | ||
| @@ -49,6 +61,31 @@ type WindowUnmapped struct{ ID WindowID } | |||||
| 49 | 61 | ||||
| 50 | func (WindowUnmapped) clientEvent() {} | 62 | func (WindowUnmapped) clientEvent() {} | ||
| 51 | 63 | ||||
| 64 | + | // UnmanagedMapped carries wayland → core that an override-redirect X11 window | |||
| 65 | + | // shows. The rect is its place in the layout (logical coordinates). NoInput | |||
| 66 | + | // windows (tooltips, drag icons, notifications) never take the pointer. | |||
| 67 | + | type UnmanagedMapped struct { | |||
| 68 | + | ID WindowID | |||
| 69 | + | X, Y, Width, Height int | |||
| 70 | + | NoInput bool | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (UnmanagedMapped) clientEvent() {} | |||
| 74 | + | ||||
| 75 | + | // UnmanagedMoved carries wayland → core a move and/or resize of a mapped | |||
| 76 | + | // unmanaged window. | |||
| 77 | + | type UnmanagedMoved struct { | |||
| 78 | + | ID WindowID | |||
| 79 | + | X, Y, Width, Height int | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (UnmanagedMoved) clientEvent() {} | |||
| 83 | + | ||||
| 84 | + | // UnmanagedUnmapped carries wayland → core that an unmanaged window is gone. | |||
| 85 | + | type UnmanagedUnmapped struct{ ID WindowID } | |||
| 86 | + | ||||
| 87 | + | func (UnmanagedUnmapped) clientEvent() {} | |||
| 88 | + | ||||
| 52 | // ShortcutsInhibit carries wayland → core that a window asks for (Active) | 89 | // ShortcutsInhibit carries wayland → core that a window asks for (Active) | ||
| 53 | // or stops asking for all keys, compositor binds included, while it has | 90 | // or stops asking for all keys, compositor binds included, while it has | ||
| 54 | // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1). | 91 | // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1). | ||
M internal/ports/config.go +35 -3
| @@ -101,9 +101,8 @@ type Config struct { | |||
| 101 | 101 | Command []string | |
| 102 | 102 | AutoOpen string | |
| 103 | 103 | } | |
| 104 | - | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | |
| 105 | - | // disables X11. | |
| 106 | - | Xwayland string | |
| 104 | + | // Xwayland selects the X11 launcher. | |
| 105 | + | Xwayland XwaylandConfig | |
| 107 | 106 | // Startup are commands run once when the session starts, in order. | |
| 108 | 107 | // One startup line may hold several, split by SplitCommands. | |
| 109 | 108 | Startup [][]string | |
| @@ -298,3 +297,36 @@ type WindowRule struct { | |||
| 298 | 297 | // (1 to 16; 0 leaves it automatic). | |
| 299 | 298 | Cells int | |
| 300 | 299 | } | |
| 300 | + | ||
| 301 | + | // XwaylandBackend selects how X11 clients are served. | |
| 302 | + | type XwaylandBackend uint8 | |
| 303 | + | ||
| 304 | + | const ( | |
| 305 | + | XwaylandOff XwaylandBackend = iota | |
| 306 | + | XwaylandSatellite | |
| 307 | + | XwaylandBuiltin | |
| 308 | + | ) | |
| 309 | + | ||
| 310 | + | // XwaylandConfig selects the launcher and its executable. | |
| 311 | + | type XwaylandConfig struct { | |
| 312 | + | Backend XwaylandBackend | |
| 313 | + | Binary string | |
| 314 | + | // XSettings (xwayland.xsettings) turns the XSETTINGS manager of the | |
| 315 | + | // built-in XWM on. | |
| 316 | + | XSettings bool | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // XwaylandSettings is the plain data the built-in XWM publishes to X11 | |
| 320 | + | // clients; the app fills it from the config and the environment. | |
| 321 | + | type XwaylandSettings struct { | |
| 322 | + | // XSettings turns the XSETTINGS manager on. | |
| 323 | + | XSettings bool | |
| 324 | + | // CursorTheme and CursorSize are XCURSOR_THEME and XCURSOR_SIZE | |
| 325 | + | // (published as Gtk/CursorThemeName and Gtk/CursorThemeSize). | |
| 326 | + | CursorTheme string | |
| 327 | + | CursorSize int | |
| 328 | + | // RootCursor sets a cursor on the X root window. Cursor is its image | |
| 329 | + | // (premultiplied ARGB8888, little-endian); empty uses the X "left_ptr" glyph. | |
| 330 | + | RootCursor bool | |
| 331 | + | Cursor CursorImage | |
| 332 | + | } | |
| @@ -101,9 +101,8 @@ type Config struct { | |||||
| 101 | Command []string | 101 | Command []string | ||
| 102 | AutoOpen string | 102 | AutoOpen string | ||
| 103 | } | 103 | } | ||
| 104 | - | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | 104 | + | // Xwayland selects the X11 launcher. |
| 105 | - | // disables X11. | 105 | + | Xwayland XwaylandConfig |
| 106 | - | Xwayland string | |||
| 107 | // Startup are commands run once when the session starts, in order. | 106 | // Startup are commands run once when the session starts, in order. | ||
| 108 | // One startup line may hold several, split by SplitCommands. | 107 | // One startup line may hold several, split by SplitCommands. | ||
| 109 | Startup [][]string | 108 | Startup [][]string | ||
| @@ -298,3 +297,36 @@ type WindowRule struct { | |||||
| 298 | // (1 to 16; 0 leaves it automatic). | 297 | // (1 to 16; 0 leaves it automatic). | ||
| 299 | Cells int | 298 | Cells int | ||
| 300 | } | 299 | } | ||
| 300 | + | ||||
| 301 | + | // XwaylandBackend selects how X11 clients are served. | |||
| 302 | + | type XwaylandBackend uint8 | |||
| 303 | + | ||||
| 304 | + | const ( | |||
| 305 | + | XwaylandOff XwaylandBackend = iota | |||
| 306 | + | XwaylandSatellite | |||
| 307 | + | XwaylandBuiltin | |||
| 308 | + | ) | |||
| 309 | + | ||||
| 310 | + | // XwaylandConfig selects the launcher and its executable. | |||
| 311 | + | type XwaylandConfig struct { | |||
| 312 | + | Backend XwaylandBackend | |||
| 313 | + | Binary string | |||
| 314 | + | // XSettings (xwayland.xsettings) turns the XSETTINGS manager of the | |||
| 315 | + | // built-in XWM on. | |||
| 316 | + | XSettings bool | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // XwaylandSettings is the plain data the built-in XWM publishes to X11 | |||
| 320 | + | // clients; the app fills it from the config and the environment. | |||
| 321 | + | type XwaylandSettings struct { | |||
| 322 | + | // XSettings turns the XSETTINGS manager on. | |||
| 323 | + | XSettings bool | |||
| 324 | + | // CursorTheme and CursorSize are XCURSOR_THEME and XCURSOR_SIZE | |||
| 325 | + | // (published as Gtk/CursorThemeName and Gtk/CursorThemeSize). | |||
| 326 | + | CursorTheme string | |||
| 327 | + | CursorSize int | |||
| 328 | + | // RootCursor sets a cursor on the X root window. Cursor is its image | |||
| 329 | + | // (premultiplied ARGB8888, little-endian); empty uses the X "left_ptr" glyph. | |||
| 330 | + | RootCursor bool | |||
| 331 | + | Cursor CursorImage | |||
| 332 | + | } | |||
Comments0 open / 0
no comments yet.