fix(core): honour the fullscreen state a window maps with
An X11 window mapping with _NET_WM_STATE_FULLSCREEN (a Proton game) was mapped floating when fixed-size, and its fullscreen request, sent after the map, was dropped by the fullscreen grace. WindowMapped now carries Fullscreen: core maps it as a tile of its own, fullscreen and focused.
7 files · +115 -7
M docs/desktop.md +1 -1
| @@ -62,7 +62,7 @@ With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and m | |||
| 62 | 62 | ||
| 63 | 63 | Games with `builtin`: | |
| 64 | 64 | ||
| 65 | - | - **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A request within 1 second of the window mapping is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later. | |
| 65 | + | - **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A window that maps with the fullscreen state already set (games do) opens fullscreen in a column of its own, focused, even if its size is fixed. A request made within 1 second *after* the map is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later. | |
| 66 | 66 | - **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it. | |
| 67 | 67 | - **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse. | |
| 68 | 68 | - **Pointer:** mouse-look works through relative pointer and pointer constraints. | |
| @@ -62,7 +62,7 @@ With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and m | |||||
| 62 | 62 | ||||
| 63 | Games with `builtin`: | 63 | Games with `builtin`: | ||
| 64 | 64 | ||||
| 65 | - | - **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A request within 1 second of the window mapping is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later. | 65 | + | - **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A window that maps with the fullscreen state already set (games do) opens fullscreen in a column of its own, focused, even if its size is fixed. A request made within 1 second *after* the map is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later. |
| 66 | - **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it. | 66 | - **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it. | ||
| 67 | - **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse. | 67 | - **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse. | ||
| 68 | - **Pointer:** mouse-look works through relative pointer and pointer constraints. | 68 | - **Pointer:** mouse-look works through relative pointer and pointer constraints. | ||
M internal/adapters/wayland/x11window.go +1 -4
| @@ -311,10 +311,7 @@ func (x *x11Window) tryMap() { | |||
| 311 | 311 | if w.parent != nil && w.parent.mapped { | |
| 312 | 312 | parent = w.parent.id | |
| 313 | 313 | } | |
| 314 | - | s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY}) | |
| 315 | - | if x.info.Fullscreen { | |
| 316 | - | s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | |
| 317 | - | } | |
| 314 | + | s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY, Fullscreen: x.info.Fullscreen}) | |
| 318 | 315 | s.syncInhibitors() | |
| 319 | 316 | s.toplevelChanged(w) | |
| 320 | 317 | s.castInventoryChanged() | |
| @@ -311,10 +311,7 @@ func (x *x11Window) tryMap() { | |||||
| 311 | if w.parent != nil && w.parent.mapped { | 311 | if w.parent != nil && w.parent.mapped { | ||
| 312 | parent = w.parent.id | 312 | parent = w.parent.id | ||
| 313 | } | 313 | } | ||
| 314 | - | s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY}) | 314 | + | s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY, Fullscreen: x.info.Fullscreen}) |
| 315 | - | if x.info.Fullscreen { | |||
| 316 | - | s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | |||
| 317 | - | } | |||
| 318 | s.syncInhibitors() | 315 | s.syncInhibitors() | ||
| 319 | s.toplevelChanged(w) | 316 | s.toplevelChanged(w) | ||
| 320 | s.castInventoryChanged() | 317 | s.castInventoryChanged() | ||
M internal/app/testdata/x11probe/main.go +16 -0
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | |
| 10 | 10 | // it unmaps it instead when the main window loses focus (prints "dismissed"). | |
| 11 | 11 | // | |
| 12 | + | // With -initial-fullscreen the window maps already fullscreen and fixed-size. | |
| 12 | 13 | // With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN | |
| 13 | 14 | // -fullscreen-delay after the window is mapped (0: right at the map) and | |
| 14 | 15 | // prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to | |
| @@ -71,6 +72,7 @@ func run() error { | |||
| 71 | 72 | fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped") | |
| 72 | 73 | fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request") | |
| 73 | 74 | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | |
| 75 | + | initialFullscreen := flag.Bool("initial-fullscreen", false, "set _NET_WM_STATE_FULLSCREEN and fixed-size WM_NORMAL_HINTS (min=max=size) before mapping") | |
| 74 | 76 | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | |
| 75 | 77 | at := flag.String("at", "0,0", "window position X,Y in root coordinates") | |
| 76 | 78 | copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window") | |
| @@ -144,6 +146,20 @@ func run() error { | |||
| 144 | 146 | cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h)) | |
| 145 | 147 | xproto.ConfigureWindow(b, win, &cv) | |
| 146 | 148 | } | |
| 149 | + | if *initialFullscreen { | |
| 150 | + | // Like a Proton game: fullscreen state and a fixed size, both set | |
| 151 | + | // before the map. | |
| 152 | + | var st [4]byte | |
| 153 | + | binary.LittleEndian.PutUint32(st[:], netWMFullscreen) | |
| 154 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.Atom(netWMState), xproto.AtomAtom, 32, 1, st[:]) | |
| 155 | + | var nh [72]byte // WM_SIZE_HINTS: flags, x, y, w, h, min, max, ... | |
| 156 | + | binary.LittleEndian.PutUint32(nh[0:], 16|32) // PMinSize|PMaxSize | |
| 157 | + | for _, off := range []int{20, 28} { | |
| 158 | + | binary.LittleEndian.PutUint32(nh[off:], uint32(w)) | |
| 159 | + | binary.LittleEndian.PutUint32(nh[off+4:], uint32(h)) | |
| 160 | + | } | |
| 161 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmNormalHints, xproto.AtomWmSizeHints, 32, 18, nh[:]) | |
| 162 | + | } | |
| 147 | 163 | xproto.MapWindow(b, win) | |
| 148 | 164 | ||
| 149 | 165 | // The override-redirect window is created once the main window is mapped. | |
| @@ -9,6 +9,7 @@ | |||||
| 9 | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | 9 | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | ||
| 10 | // it unmaps it instead when the main window loses focus (prints "dismissed"). | 10 | // it unmaps it instead when the main window loses focus (prints "dismissed"). | ||
| 11 | // | 11 | // | ||
| 12 | + | // With -initial-fullscreen the window maps already fullscreen and fixed-size. | |||
| 12 | // With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN | 13 | // With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN | ||
| 13 | // -fullscreen-delay after the window is mapped (0: right at the map) and | 14 | // -fullscreen-delay after the window is mapped (0: right at the map) and | ||
| 14 | // prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to | 15 | // prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to | ||
| @@ -71,6 +72,7 @@ func run() error { | |||||
| 71 | fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped") | 72 | fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped") | ||
| 72 | fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request") | 73 | fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request") | ||
| 73 | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | 74 | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | ||
| 75 | + | initialFullscreen := flag.Bool("initial-fullscreen", false, "set _NET_WM_STATE_FULLSCREEN and fixed-size WM_NORMAL_HINTS (min=max=size) before mapping") | |||
| 74 | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | 76 | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | ||
| 75 | at := flag.String("at", "0,0", "window position X,Y in root coordinates") | 77 | at := flag.String("at", "0,0", "window position X,Y in root coordinates") | ||
| 76 | copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window") | 78 | copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window") | ||
| @@ -144,6 +146,20 @@ func run() error { | |||||
| 144 | cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h)) | 146 | cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h)) | ||
| 145 | xproto.ConfigureWindow(b, win, &cv) | 147 | xproto.ConfigureWindow(b, win, &cv) | ||
| 146 | } | 148 | } | ||
| 149 | + | if *initialFullscreen { | |||
| 150 | + | // Like a Proton game: fullscreen state and a fixed size, both set | |||
| 151 | + | // before the map. | |||
| 152 | + | var st [4]byte | |||
| 153 | + | binary.LittleEndian.PutUint32(st[:], netWMFullscreen) | |||
| 154 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.Atom(netWMState), xproto.AtomAtom, 32, 1, st[:]) | |||
| 155 | + | var nh [72]byte // WM_SIZE_HINTS: flags, x, y, w, h, min, max, ... | |||
| 156 | + | binary.LittleEndian.PutUint32(nh[0:], 16|32) // PMinSize|PMaxSize | |||
| 157 | + | for _, off := range []int{20, 28} { | |||
| 158 | + | binary.LittleEndian.PutUint32(nh[off:], uint32(w)) | |||
| 159 | + | binary.LittleEndian.PutUint32(nh[off+4:], uint32(h)) | |||
| 160 | + | } | |||
| 161 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmNormalHints, xproto.AtomWmSizeHints, 32, 18, nh[:]) | |||
| 162 | + | } | |||
| 147 | xproto.MapWindow(b, win) | 163 | xproto.MapWindow(b, win) | ||
| 148 | 164 | ||||
| 149 | // The override-redirect window is created once the main window is mapped. | 165 | // The override-redirect window is created once the main window is mapped. | ||
M internal/app/xwayland_test.go +28 -2
| @@ -458,8 +458,34 @@ func TestXwaylandFullscreenOff(t *testing.T) { | |||
| 458 | 458 | }) | |
| 459 | 459 | } | |
| 460 | 460 | ||
| 461 | - | // TestXwaylandFullscreenGrace: a request made at once after the map stays | |
| 462 | - | // tiled (fullscreenGrace in core). | |
| 461 | + | // TestXwaylandInitialFullscreen: a fixed-size window that maps with | |
| 462 | + | // _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a | |
| 463 | + | // column of its own, focused, beside the tiled window already open. This is | |
| 464 | + | // the state it maps with, not a request: the grace does not apply. | |
| 465 | + | func TestXwaylandInitialFullscreen(t *testing.T) { | |
| 466 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |
| 467 | + | shot := filepath.Join(e.shots, "latest.png") | |
| 468 | + | first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100") | |
| 469 | + | until(t, "first tiled", func() bool { | |
| 470 | + | return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1 | |
| 471 | + | }) | |
| 472 | + | p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360") | |
| 473 | + | until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |
| 474 | + | until(t, "game in its own column, fullscreen", func() bool { | |
| 475 | + | st := readState(e.statePath) | |
| 476 | + | for _, w := range st.Windows { | |
| 477 | + | if w.AppID == "Game" { | |
| 478 | + | return !w.Floating && w.Column == 2 && w.Row == 1 | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | return false | |
| 482 | + | }) | |
| 483 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |
| 484 | + | until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") }) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // TestXwaylandFullscreenGrace: a request made at once after the map (not the | |
| 488 | + | // state it maps with) stays tiled (fullscreenGrace in core). | |
| 463 | 489 | func TestXwaylandFullscreenGrace(t *testing.T) { | |
| 464 | 490 | e := startXwayland(t) | |
| 465 | 491 | p := startProbe(t, e.bin, e.display, "-fullscreen") | |
| @@ -458,8 +458,34 @@ func TestXwaylandFullscreenOff(t *testing.T) { | |||||
| 458 | }) | 458 | }) | ||
| 459 | } | 459 | } | ||
| 460 | 460 | ||||
| 461 | - | // TestXwaylandFullscreenGrace: a request made at once after the map stays | 461 | + | // TestXwaylandInitialFullscreen: a fixed-size window that maps with |
| 462 | - | // tiled (fullscreenGrace in core). | 462 | + | // _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a |
| 463 | + | // column of its own, focused, beside the tiled window already open. This is | |||
| 464 | + | // the state it maps with, not a request: the grace does not apply. | |||
| 465 | + | func TestXwaylandInitialFullscreen(t *testing.T) { | |||
| 466 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |||
| 467 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 468 | + | first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100") | |||
| 469 | + | until(t, "first tiled", func() bool { | |||
| 470 | + | return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1 | |||
| 471 | + | }) | |||
| 472 | + | p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360") | |||
| 473 | + | until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 474 | + | until(t, "game in its own column, fullscreen", func() bool { | |||
| 475 | + | st := readState(e.statePath) | |||
| 476 | + | for _, w := range st.Windows { | |||
| 477 | + | if w.AppID == "Game" { | |||
| 478 | + | return !w.Floating && w.Column == 2 && w.Row == 1 | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | return false | |||
| 482 | + | }) | |||
| 483 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |||
| 484 | + | until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // TestXwaylandFullscreenGrace: a request made at once after the map (not the | |||
| 488 | + | // state it maps with) stays tiled (fullscreenGrace in core). | |||
| 463 | func TestXwaylandFullscreenGrace(t *testing.T) { | 489 | func TestXwaylandFullscreenGrace(t *testing.T) { | ||
| 464 | e := startXwayland(t) | 490 | e := startXwayland(t) | ||
| 465 | p := startProbe(t, e.bin, e.display, "-fullscreen") | 491 | p := startProbe(t, e.bin, e.display, "-fullscreen") | ||
M internal/core/core_test.go +46 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "reflect" | |
| 7 | + | "regexp" | |
| 7 | 8 | "slices" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "testing/synctest" | |
| @@ -1254,3 +1255,48 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||
| 1254 | 1255 | } | |
| 1255 | 1256 | ||
| 1256 | 1257 | } | |
| 1258 | + | ||
| 1259 | + | // A window that maps with the fullscreen state (an X11 game with | |
| 1260 | + | // _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused, | |
| 1261 | + | // in a tile of its own even when it was classified floating; a rule that | |
| 1262 | + | // floats it does not change that. The grace still drops a later request. | |
| 1263 | + | func TestMappedFullscreenHonoured(t *testing.T) { | |
| 1264 | + | for _, tc := range []struct { | |
| 1265 | + | name string | |
| 1266 | + | rule bool | |
| 1267 | + | }{{"fixed size", false}, {"floating rule", true}} { | |
| 1268 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1269 | + | cfg := scrollDefaults() | |
| 1270 | + | cfg.Border.Width = 0 | |
| 1271 | + | if tc.rule { | |
| 1272 | + | on := true | |
| 1273 | + | cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}} | |
| 1274 | + | } | |
| 1275 | + | client := make(chan ports.ClientEvent, 8) | |
| 1276 | + | output := make(chan ports.OutputEvent, 8) | |
| 1277 | + | commands := make(chan ports.ClientCommand, 64) | |
| 1278 | + | scenes := make(chan []ports.Scene, 1) | |
| 1279 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)}) | |
| 1280 | + | if err != nil { | |
| 1281 | + | t.Fatal(err) | |
| 1282 | + | } | |
| 1283 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 1284 | + | defer cancel() | |
| 1285 | + | go c.Run(ctx) | |
| 1286 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 1287 | + | scene(t, scenes) | |
| 1288 | + | client <- ports.WindowMapped{ID: 1} | |
| 1289 | + | scene(t, scenes) | |
| 1290 | + | client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true} | |
| 1291 | + | s := scene(t, scenes) | |
| 1292 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) { | |
| 1293 | + | s = scene(t, scenes) | |
| 1294 | + | } | |
| 1295 | + | for _, w := range s.Windows { | |
| 1296 | + | if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) { | |
| 1297 | + | t.Fatalf("fullscreen rect %+v, want the output", w.Rect) | |
| 1298 | + | } | |
| 1299 | + | } | |
| 1300 | + | }) | |
| 1301 | + | } | |
| 1302 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "reflect" | 6 | "reflect" | ||
| 7 | + | "regexp" | |||
| 7 | "slices" | 8 | "slices" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "testing/synctest" | 10 | "testing/synctest" | ||
| @@ -1254,3 +1255,48 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||||
| 1254 | } | 1255 | } | ||
| 1255 | 1256 | ||||
| 1256 | } | 1257 | } | ||
| 1258 | + | ||||
| 1259 | + | // A window that maps with the fullscreen state (an X11 game with | |||
| 1260 | + | // _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused, | |||
| 1261 | + | // in a tile of its own even when it was classified floating; a rule that | |||
| 1262 | + | // floats it does not change that. The grace still drops a later request. | |||
| 1263 | + | func TestMappedFullscreenHonoured(t *testing.T) { | |||
| 1264 | + | for _, tc := range []struct { | |||
| 1265 | + | name string | |||
| 1266 | + | rule bool | |||
| 1267 | + | }{{"fixed size", false}, {"floating rule", true}} { | |||
| 1268 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1269 | + | cfg := scrollDefaults() | |||
| 1270 | + | cfg.Border.Width = 0 | |||
| 1271 | + | if tc.rule { | |||
| 1272 | + | on := true | |||
| 1273 | + | cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}} | |||
| 1274 | + | } | |||
| 1275 | + | client := make(chan ports.ClientEvent, 8) | |||
| 1276 | + | output := make(chan ports.OutputEvent, 8) | |||
| 1277 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 1278 | + | scenes := make(chan []ports.Scene, 1) | |||
| 1279 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)}) | |||
| 1280 | + | if err != nil { | |||
| 1281 | + | t.Fatal(err) | |||
| 1282 | + | } | |||
| 1283 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1284 | + | defer cancel() | |||
| 1285 | + | go c.Run(ctx) | |||
| 1286 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 1287 | + | scene(t, scenes) | |||
| 1288 | + | client <- ports.WindowMapped{ID: 1} | |||
| 1289 | + | scene(t, scenes) | |||
| 1290 | + | client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true} | |||
| 1291 | + | s := scene(t, scenes) | |||
| 1292 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) { | |||
| 1293 | + | s = scene(t, scenes) | |||
| 1294 | + | } | |||
| 1295 | + | for _, w := range s.Windows { | |||
| 1296 | + | if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) { | |||
| 1297 | + | t.Fatalf("fullscreen rect %+v, want the output", w.Rect) | |||
| 1298 | + | } | |||
| 1299 | + | } | |||
| 1300 | + | }) | |||
| 1301 | + | } | |||
| 1302 | + | } | |||
M internal/core/transition.go +19 -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 | |
| @@ -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 | ||
M internal/ports/client_events.go +4 -0
| @@ -29,6 +29,10 @@ type WindowMapped struct { | |||
| 29 | 29 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | |
| 30 | 30 | // focusing the parent focuses the dialog. | |
| 31 | 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 | |
| 32 | 36 | // HasAt asks for the screen containing the layout point (AtX, AtY) when | |
| 33 | 37 | // no rule sets a monitor: the X11 host sets it for a window that maps at | |
| 34 | 38 | // exactly one output's rect. | |
| @@ -29,6 +29,10 @@ type WindowMapped struct { | |||||
| 29 | // 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): | ||
| 30 | // focusing the parent focuses the dialog. | 30 | // focusing the parent focuses the dialog. | ||
| 31 | 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 | |||
| 32 | // HasAt asks for the screen containing the layout point (AtX, AtY) when | 36 | // HasAt asks for the screen containing the layout point (AtX, AtY) when | ||
| 33 | // no rule sets a monitor: the X11 host sets it for a window that maps at | 37 | // no rule sets a monitor: the X11 host sets it for a window that maps at | ||
| 34 | // exactly one output's rect. | 38 | // exactly one output's rect. | ||