Merge bc208baf6bbbcf50383df44041e1f92c1f500980 into f2d166b7fd4fa830ec06f487fcdcc64f1eacb4c9
6 files · +307 -3
M docs/config.md +2 -0
| @@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |||
| 260 | 260 | | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config | | |
| 261 | 261 | | `quit` | Exit NeferWL | | |
| 262 | 262 | ||
| 263 | + | A parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not. | |
| 264 | + | ||
| 263 | 265 | ### Default binds | |
| 264 | 266 | ||
| 265 | 267 | | Keys | Action | | |
| @@ -260,6 +260,8 @@ bind.XF86MonBrightnessDown = spawn brightnessctl set 5%- | |||||
| 260 | | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config | | 260 | | `scale-up` / `scale-down` | Zoom the display; the scale is saved to the config | | ||
| 261 | | `quit` | Exit NeferWL | | 261 | | `quit` | Exit NeferWL | | ||
| 262 | 262 | ||||
| 263 | + | A parentless window whose first committed size is exactly the logical size of the output it is on, without asking for fullscreen (a game in borderless-windowed mode), is put fullscreen as if by `toggle-fullscreen`, without being resized, when it is the focused window of the shown workspace. Later resizes do not. | |||
| 264 | + | ||||
| 263 | ### Default binds | 265 | ### Default binds | ||
| 264 | 266 | ||||
| 265 | | Keys | Action | | 267 | | Keys | Action | | ||
A internal/adapters/wayland/legacy_fullscreen_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // outputSizedServer runs a server whose only output is w×h logical. | |
| 13 | + | func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) { | |
| 14 | + | t.Helper() | |
| 15 | + | dir := t.TempDir() | |
| 16 | + | events := make(chan ports.ClientEvent, 16) | |
| 17 | + | out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}} | |
| 18 | + | s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland")) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 23 | + | done := make(chan error, 1) | |
| 24 | + | go func() { done <- s.Run(ctx) }() | |
| 25 | + | t.Cleanup(func() { | |
| 26 | + | cancel() | |
| 27 | + | <-done | |
| 28 | + | }) | |
| 29 | + | return s, events, dir | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // A tiled toplevel whose first committed size is its output's size reports | |
| 33 | + | // it once (core then fullscreens it); a smaller one reports nothing. | |
| 34 | + | func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) { | |
| 35 | + | s, events, dir := outputSizedServer(t, 1, 1) | |
| 36 | + | c := protocolClient(t, s, dir) | |
| 37 | + | w := toplevelMapper(t, c, events)() | |
| 38 | + | if w.Floating { | |
| 39 | + | t.Fatal("expected a tiled window") | |
| 40 | + | } | |
| 41 | + | ev := waitEvent(t, events, 2*time.Second) | |
| 42 | + | if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 { | |
| 43 | + | t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) { | |
| 48 | + | s, events, dir := outputSizedServer(t, 2, 2) | |
| 49 | + | c := protocolClient(t, s, dir) | |
| 50 | + | toplevelMapper(t, c, events)() | |
| 51 | + | if err := c.Roundtrip(); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | select { | |
| 55 | + | case ev := <-events: | |
| 56 | + | t.Fatalf("unexpected event %#v", ev) | |
| 57 | + | case <-time.After(200 * time.Millisecond): | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // outputSizedServer runs a server whose only output is w×h logical. | |||
| 13 | + | func outputSizedServer(t *testing.T, w, h int) (*Server, chan ports.ClientEvent, string) { | |||
| 14 | + | t.Helper() | |||
| 15 | + | dir := t.TempDir() | |||
| 16 | + | events := make(chan ports.ClientEvent, 16) | |||
| 17 | + | out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: w, Height: h}, Width: w, Height: h, Scale: 1}} | |||
| 18 | + | s, err := New(Options{RuntimeDir: dir, Outputs: out}, Channels{Events: events, Commands: make(chan ports.ClientCommand, 16)}, logging.For(context.Background(), "wayland")) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 23 | + | done := make(chan error, 1) | |||
| 24 | + | go func() { done <- s.Run(ctx) }() | |||
| 25 | + | t.Cleanup(func() { | |||
| 26 | + | cancel() | |||
| 27 | + | <-done | |||
| 28 | + | }) | |||
| 29 | + | return s, events, dir | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // A tiled toplevel whose first committed size is its output's size reports | |||
| 33 | + | // it once (core then fullscreens it); a smaller one reports nothing. | |||
| 34 | + | func TestTiledFirstSizeAtOutputSizeEmitsResized(t *testing.T) { | |||
| 35 | + | s, events, dir := outputSizedServer(t, 1, 1) | |||
| 36 | + | c := protocolClient(t, s, dir) | |||
| 37 | + | w := toplevelMapper(t, c, events)() | |||
| 38 | + | if w.Floating { | |||
| 39 | + | t.Fatal("expected a tiled window") | |||
| 40 | + | } | |||
| 41 | + | ev := waitEvent(t, events, 2*time.Second) | |||
| 42 | + | if r, ok := ev.(ports.WindowResized); !ok || r.ID != w.ID || r.Width != 1 || r.Height != 1 { | |||
| 43 | + | t.Fatalf("got %#v, want WindowResized 1x1 for %d", ev, w.ID) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestTiledSmallerThanOutputEmitsNoResized(t *testing.T) { | |||
| 48 | + | s, events, dir := outputSizedServer(t, 2, 2) | |||
| 49 | + | c := protocolClient(t, s, dir) | |||
| 50 | + | toplevelMapper(t, c, events)() | |||
| 51 | + | if err := c.Roundtrip(); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | select { | |||
| 55 | + | case ev := <-events: | |||
| 56 | + | t.Fatalf("unexpected event %#v", ev) | |||
| 57 | + | case <-time.After(200 * time.Millisecond): | |||
| 58 | + | } | |||
| 59 | + | } | |||
M internal/adapters/wayland/xdg.go +28 -2
| @@ -145,7 +145,8 @@ type window struct { | |||
| 145 | 145 | minW, minH int32 | |
| 146 | 146 | maxW, maxH int32 | |
| 147 | 147 | floating bool | |
| 148 | - | floatW, floatH int // size last sent to core | |
| 148 | + | floatW, floatH int // size last sent to core | |
| 149 | + | sized bool // the first committed size after a map was sent | |
| 149 | 150 | // last is the most recent core configure, resent when decoration mode changes. | |
| 150 | 151 | last ports.ConfigureWindow | |
| 151 | 152 | hasLast bool | |
| @@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 238 | 239 | x.serials = append(x.serials, x.server.serial) | |
| 239 | 240 | } else if buffer && !w.mapped && x.acked { | |
| 240 | 241 | w.mapped = true | |
| 242 | + | w.sized = false | |
| 241 | 243 | w.gen++ | |
| 242 | 244 | // Placement claims are for a new toplevel, not later remaps. | |
| 243 | 245 | // Ordinary commits, popups and remaps need no /proc lookup. | |
| @@ -412,7 +414,21 @@ func (top) SetMinimized(*xdgshell.Toplevel) {} | |||
| 412 | 414 | // afterCommit runs once the commit applied content and geometry: a | |
| 413 | 415 | // floating window keeps the size it draws. | |
| 414 | 416 | func (w *window) afterCommit() { | |
| 415 | - | if !w.mapped || !w.floating { | |
| 417 | + | if !w.mapped { | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | if !w.floating { | |
| 421 | + | // Core is authoritative: this prefilter matches any output, and | |
| 422 | + | // core compares with the output the window is on. | |
| 423 | + | // Core reads a tile's first size once, to spot a window that maps | |
| 424 | + | // at its output's size without asking for fullscreen. | |
| 425 | + | if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 { | |
| 426 | + | w.sized = true | |
| 427 | + | if !w.xdg.server.outputSized(fw, fh) { | |
| 428 | + | return | |
| 429 | + | } | |
| 430 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |
| 431 | + | } | |
| 416 | 432 | return | |
| 417 | 433 | } | |
| 418 | 434 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | |
| @@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface { | |||
| 453 | 469 | } | |
| 454 | 470 | return nil | |
| 455 | 471 | } | |
| 472 | + | ||
| 473 | + | // outputSized reports whether an output has the logical size w×h. | |
| 474 | + | func (s *Server) outputSized(w, h int) bool { | |
| 475 | + | for _, o := range s.outputs { | |
| 476 | + | if o.place.Width == w && o.place.Height == h { | |
| 477 | + | return true | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | return false | |
| 481 | + | } | |
| @@ -145,7 +145,8 @@ type window struct { | |||||
| 145 | minW, minH int32 | 145 | minW, minH int32 | ||
| 146 | maxW, maxH int32 | 146 | maxW, maxH int32 | ||
| 147 | floating bool | 147 | floating bool | ||
| 148 | - | floatW, floatH int // size last sent to core | 148 | + | floatW, floatH int // size last sent to core |
| 149 | + | sized bool // the first committed size after a map was sent | |||
| 149 | // last is the most recent core configure, resent when decoration mode changes. | 150 | // last is the most recent core configure, resent when decoration mode changes. | ||
| 150 | last ports.ConfigureWindow | 151 | last ports.ConfigureWindow | ||
| 151 | hasLast bool | 152 | hasLast bool | ||
| @@ -238,6 +239,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 238 | x.serials = append(x.serials, x.server.serial) | 239 | x.serials = append(x.serials, x.server.serial) | ||
| 239 | } else if buffer && !w.mapped && x.acked { | 240 | } else if buffer && !w.mapped && x.acked { | ||
| 240 | w.mapped = true | 241 | w.mapped = true | ||
| 242 | + | w.sized = false | |||
| 241 | w.gen++ | 243 | w.gen++ | ||
| 242 | // Placement claims are for a new toplevel, not later remaps. | 244 | // Placement claims are for a new toplevel, not later remaps. | ||
| 243 | // Ordinary commits, popups and remaps need no /proc lookup. | 245 | // Ordinary commits, popups and remaps need no /proc lookup. | ||
| @@ -412,7 +414,21 @@ func (top) SetMinimized(*xdgshell.Toplevel) {} | |||||
| 412 | // afterCommit runs once the commit applied content and geometry: a | 414 | // afterCommit runs once the commit applied content and geometry: a | ||
| 413 | // floating window keeps the size it draws. | 415 | // floating window keeps the size it draws. | ||
| 414 | func (w *window) afterCommit() { | 416 | func (w *window) afterCommit() { | ||
| 415 | - | if !w.mapped || !w.floating { | 417 | + | if !w.mapped { |
| 418 | + | return | |||
| 419 | + | } | |||
| 420 | + | if !w.floating { | |||
| 421 | + | // Core is authoritative: this prefilter matches any output, and | |||
| 422 | + | // core compares with the output the window is on. | |||
| 423 | + | // Core reads a tile's first size once, to spot a window that maps | |||
| 424 | + | // at its output's size without asking for fullscreen. | |||
| 425 | + | if fw, fh := w.size(); !w.sized && fw > 0 && fh > 0 { | |||
| 426 | + | w.sized = true | |||
| 427 | + | if !w.xdg.server.outputSized(fw, fh) { | |||
| 428 | + | return | |||
| 429 | + | } | |||
| 430 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |||
| 431 | + | } | |||
| 416 | return | 432 | return | ||
| 417 | } | 433 | } | ||
| 418 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | 434 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | ||
| @@ -453,3 +469,13 @@ func (x *xdgSurface) surfaceResource() *wayland.Surface { | |||||
| 453 | } | 469 | } | ||
| 454 | return nil | 470 | return nil | ||
| 455 | } | 471 | } | ||
| 472 | + | ||||
| 473 | + | // outputSized reports whether an output has the logical size w×h. | |||
| 474 | + | func (s *Server) outputSized(w, h int) bool { | |||
| 475 | + | for _, o := range s.outputs { | |||
| 476 | + | if o.place.Width == w && o.place.Height == h { | |||
| 477 | + | return true | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | return false | |||
| 481 | + | } | |||
A internal/core/legacy_fullscreen_internal_test.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // A window whose first committed size is its output's size goes fullscreen, | |
| 10 | + | // focused, exactly as toggle-fullscreen does, and toggling leaves it. | |
| 11 | + | func TestLegacyFullscreenAtOutputSize(t *testing.T) { | |
| 12 | + | c, _, _ := fadeCore(t) | |
| 13 | + | sc := c.cur() | |
| 14 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 15 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 16 | + | o := sc.rect() | |
| 17 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 18 | + | w := sc.mon.Current() | |
| 19 | + | if w.fullscreen != 2 { | |
| 20 | + | t.Fatalf("fullscreen = %d, want 2", w.fullscreen) | |
| 21 | + | } | |
| 22 | + | if id, _ := w.Focused(); id != 2 { | |
| 23 | + | t.Fatalf("focused %d, want 2", id) | |
| 24 | + | } | |
| 25 | + | sc.mon.ToggleFullscreen() | |
| 26 | + | if w.fullscreen != 0 { | |
| 27 | + | t.Fatalf("toggle left fullscreen = %d", w.fullscreen) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestLegacyFullscreenFloatingFixedSize(t *testing.T) { | |
| 32 | + | c, _, _ := fadeCore(t) | |
| 33 | + | sc := c.cur() | |
| 34 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |
| 35 | + | o := sc.rect() | |
| 36 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 37 | + | if sc.mon.Current().fullscreen != 1 { | |
| 38 | + | t.Fatal("a screen-sized float did not go fullscreen") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestLegacyFullscreenNoFalsePositive(t *testing.T) { | |
| 43 | + | t.Run("smaller", func(t *testing.T) { | |
| 44 | + | c, _, _ := fadeCore(t) | |
| 45 | + | sc := c.cur() | |
| 46 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 47 | + | o := sc.rect() | |
| 48 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H}) | |
| 49 | + | if sc.mon.Current().fullscreen != 0 { | |
| 50 | + | t.Fatal("smaller window went fullscreen") | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | t.Run("parent", func(t *testing.T) { | |
| 54 | + | c, _, _ := fadeCore(t) | |
| 55 | + | sc := c.cur() | |
| 56 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 57 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1}) | |
| 58 | + | o := sc.rect() | |
| 59 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 60 | + | if sc.mon.Current().fullscreen != 0 { | |
| 61 | + | t.Fatal("dialog went fullscreen") | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | t.Run("later resize", func(t *testing.T) { | |
| 65 | + | c, _, _ := fadeCore(t) | |
| 66 | + | sc := c.cur() | |
| 67 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 68 | + | o := sc.rect() | |
| 69 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50}) | |
| 70 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 71 | + | if sc.mon.Current().fullscreen != 0 { | |
| 72 | + | t.Fatal("a later resize went fullscreen") | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // With animations on, the entrance never restarts for the window that went | |
| 78 | + | // fullscreen: no motion, and the scene shows it at the full output. | |
| 79 | + | func TestLegacyFullscreenDropsEntrance(t *testing.T) { | |
| 80 | + | c, _, _ := fadeCore(t) | |
| 81 | + | c.cfg.Animations.On = true | |
| 82 | + | sc := c.cur() | |
| 83 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 84 | + | c.stopAnimations() | |
| 85 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 86 | + | o := sc.rect() | |
| 87 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 88 | + | s := indicatorScene(t, c) | |
| 89 | + | if _, ok := sc.rects[2]; ok { | |
| 90 | + | t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects) | |
| 91 | + | } | |
| 92 | + | if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 { | |
| 93 | + | t.Fatalf("scene window %+v, want the full output %+v", w, o) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor | |
| 98 | + | // acts on a workspace that is not shown. | |
| 99 | + | func TestLegacyFullscreenRespectsFocus(t *testing.T) { | |
| 100 | + | t.Run("under a fullscreen window", func(t *testing.T) { | |
| 101 | + | c, _, _ := fadeCore(t) | |
| 102 | + | sc := c.cur() | |
| 103 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 104 | + | sc.mon.SetFullscreen(1, true) | |
| 105 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |
| 106 | + | o := sc.rect() | |
| 107 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |
| 108 | + | w := sc.mon.Current() | |
| 109 | + | if w.fullscreen != 1 { | |
| 110 | + | t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen) | |
| 111 | + | } | |
| 112 | + | if id, _ := w.Focused(); id != 1 { | |
| 113 | + | t.Fatalf("focus moved to %d", id) | |
| 114 | + | } | |
| 115 | + | }) | |
| 116 | + | t.Run("other workspace", func(t *testing.T) { | |
| 117 | + | c, _, _ := fadeCore(t) | |
| 118 | + | sc := c.cur() | |
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 120 | + | ws := sc.mon.Current() | |
| 121 | + | sc.mon.Focus(1) | |
| 122 | + | if sc.mon.Current() == ws { | |
| 123 | + | t.Skip("no second workspace") | |
| 124 | + | } | |
| 125 | + | o := sc.rect() | |
| 126 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 127 | + | if ws.fullscreen != 0 { | |
| 128 | + | t.Fatal("a window on a hidden workspace went fullscreen") | |
| 129 | + | } | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestLegacyFullscreenRemapAndOutputs(t *testing.T) { | |
| 134 | + | t.Run("remap", func(t *testing.T) { | |
| 135 | + | c, _, _ := fadeCore(t) | |
| 136 | + | sc := c.cur() | |
| 137 | + | c.mapWindow(ports.WindowMapped{ID: 1, Remap: true}) | |
| 138 | + | o := sc.rect() | |
| 139 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |
| 140 | + | if sc.mon.Current().fullscreen != 0 { | |
| 141 | + | t.Fatal("a remap went fullscreen") | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | second := func(t *testing.T) (*Core, *screen) { | |
| 145 | + | c, _, _ := fadeCore(t) | |
| 146 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300}) | |
| 147 | + | c.focusScreen = c.screenIndex("B") | |
| 148 | + | return c, c.cur() | |
| 149 | + | } | |
| 150 | + | t.Run("sized to its own output", func(t *testing.T) { | |
| 151 | + | c, b := second(t) | |
| 152 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 153 | + | if s, _ := c.screenOf(1); s != b { | |
| 154 | + | t.Fatal("window not on B") | |
| 155 | + | } | |
| 156 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300}) | |
| 157 | + | if b.mon.Current().fullscreen != 1 { | |
| 158 | + | t.Fatal("window sized to its output B did not go fullscreen") | |
| 159 | + | } | |
| 160 | + | }) | |
| 161 | + | t.Run("sized to another output", func(t *testing.T) { | |
| 162 | + | c, b := second(t) | |
| 163 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |
| 164 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200}) | |
| 165 | + | if b.mon.Current().fullscreen != 0 { | |
| 166 | + | t.Fatal("window sized to output A went fullscreen on B") | |
| 167 | + | } | |
| 168 | + | }) | |
| 169 | + | } | |
| @@ -0,0 +1,169 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // A window whose first committed size is its output's size goes fullscreen, | |||
| 10 | + | // focused, exactly as toggle-fullscreen does, and toggling leaves it. | |||
| 11 | + | func TestLegacyFullscreenAtOutputSize(t *testing.T) { | |||
| 12 | + | c, _, _ := fadeCore(t) | |||
| 13 | + | sc := c.cur() | |||
| 14 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 15 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 16 | + | o := sc.rect() | |||
| 17 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 18 | + | w := sc.mon.Current() | |||
| 19 | + | if w.fullscreen != 2 { | |||
| 20 | + | t.Fatalf("fullscreen = %d, want 2", w.fullscreen) | |||
| 21 | + | } | |||
| 22 | + | if id, _ := w.Focused(); id != 2 { | |||
| 23 | + | t.Fatalf("focused %d, want 2", id) | |||
| 24 | + | } | |||
| 25 | + | sc.mon.ToggleFullscreen() | |||
| 26 | + | if w.fullscreen != 0 { | |||
| 27 | + | t.Fatalf("toggle left fullscreen = %d", w.fullscreen) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestLegacyFullscreenFloatingFixedSize(t *testing.T) { | |||
| 32 | + | c, _, _ := fadeCore(t) | |||
| 33 | + | sc := c.cur() | |||
| 34 | + | c.mapWindow(ports.WindowMapped{ID: 1, Floating: true}) | |||
| 35 | + | o := sc.rect() | |||
| 36 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 37 | + | if sc.mon.Current().fullscreen != 1 { | |||
| 38 | + | t.Fatal("a screen-sized float did not go fullscreen") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestLegacyFullscreenNoFalsePositive(t *testing.T) { | |||
| 43 | + | t.Run("smaller", func(t *testing.T) { | |||
| 44 | + | c, _, _ := fadeCore(t) | |||
| 45 | + | sc := c.cur() | |||
| 46 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 47 | + | o := sc.rect() | |||
| 48 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W - 1, Height: o.H}) | |||
| 49 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 50 | + | t.Fatal("smaller window went fullscreen") | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 53 | + | t.Run("parent", func(t *testing.T) { | |||
| 54 | + | c, _, _ := fadeCore(t) | |||
| 55 | + | sc := c.cur() | |||
| 56 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 57 | + | c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Parent: 1}) | |||
| 58 | + | o := sc.rect() | |||
| 59 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 60 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 61 | + | t.Fatal("dialog went fullscreen") | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | t.Run("later resize", func(t *testing.T) { | |||
| 65 | + | c, _, _ := fadeCore(t) | |||
| 66 | + | sc := c.cur() | |||
| 67 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 68 | + | o := sc.rect() | |||
| 69 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 50, Height: 50}) | |||
| 70 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 71 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 72 | + | t.Fatal("a later resize went fullscreen") | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // With animations on, the entrance never restarts for the window that went | |||
| 78 | + | // fullscreen: no motion, and the scene shows it at the full output. | |||
| 79 | + | func TestLegacyFullscreenDropsEntrance(t *testing.T) { | |||
| 80 | + | c, _, _ := fadeCore(t) | |||
| 81 | + | c.cfg.Animations.On = true | |||
| 82 | + | sc := c.cur() | |||
| 83 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 84 | + | c.stopAnimations() | |||
| 85 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 86 | + | o := sc.rect() | |||
| 87 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 88 | + | s := indicatorScene(t, c) | |||
| 89 | + | if _, ok := sc.rects[2]; ok { | |||
| 90 | + | t.Fatalf("entrance motion left for the fullscreen window: %+v", sc.rects) | |||
| 91 | + | } | |||
| 92 | + | if w := sceneWindow(t, s, 2); w.Rect != (Rect{W: o.W, H: o.H}) || w.Fade != 0 { | |||
| 93 | + | t.Fatalf("scene window %+v, want the full output %+v", w, o) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Focus rules (ADR 011): never takes the focus from a fullscreen cover, nor | |||
| 98 | + | // acts on a workspace that is not shown. | |||
| 99 | + | func TestLegacyFullscreenRespectsFocus(t *testing.T) { | |||
| 100 | + | t.Run("under a fullscreen window", func(t *testing.T) { | |||
| 101 | + | c, _, _ := fadeCore(t) | |||
| 102 | + | sc := c.cur() | |||
| 103 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 104 | + | sc.mon.SetFullscreen(1, true) | |||
| 105 | + | c.mapWindow(ports.WindowMapped{ID: 2}) | |||
| 106 | + | o := sc.rect() | |||
| 107 | + | c.resizeFloating(ports.WindowResized{ID: 2, Width: o.W, Height: o.H}) | |||
| 108 | + | w := sc.mon.Current() | |||
| 109 | + | if w.fullscreen != 1 { | |||
| 110 | + | t.Fatalf("fullscreen = %d, want the cover 1", w.fullscreen) | |||
| 111 | + | } | |||
| 112 | + | if id, _ := w.Focused(); id != 1 { | |||
| 113 | + | t.Fatalf("focus moved to %d", id) | |||
| 114 | + | } | |||
| 115 | + | }) | |||
| 116 | + | t.Run("other workspace", func(t *testing.T) { | |||
| 117 | + | c, _, _ := fadeCore(t) | |||
| 118 | + | sc := c.cur() | |||
| 119 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 120 | + | ws := sc.mon.Current() | |||
| 121 | + | sc.mon.Focus(1) | |||
| 122 | + | if sc.mon.Current() == ws { | |||
| 123 | + | t.Skip("no second workspace") | |||
| 124 | + | } | |||
| 125 | + | o := sc.rect() | |||
| 126 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 127 | + | if ws.fullscreen != 0 { | |||
| 128 | + | t.Fatal("a window on a hidden workspace went fullscreen") | |||
| 129 | + | } | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestLegacyFullscreenRemapAndOutputs(t *testing.T) { | |||
| 134 | + | t.Run("remap", func(t *testing.T) { | |||
| 135 | + | c, _, _ := fadeCore(t) | |||
| 136 | + | sc := c.cur() | |||
| 137 | + | c.mapWindow(ports.WindowMapped{ID: 1, Remap: true}) | |||
| 138 | + | o := sc.rect() | |||
| 139 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: o.W, Height: o.H}) | |||
| 140 | + | if sc.mon.Current().fullscreen != 0 { | |||
| 141 | + | t.Fatal("a remap went fullscreen") | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | second := func(t *testing.T) (*Core, *screen) { | |||
| 145 | + | c, _, _ := fadeCore(t) | |||
| 146 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 500, Height: 300}) | |||
| 147 | + | c.focusScreen = c.screenIndex("B") | |||
| 148 | + | return c, c.cur() | |||
| 149 | + | } | |||
| 150 | + | t.Run("sized to its own output", func(t *testing.T) { | |||
| 151 | + | c, b := second(t) | |||
| 152 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 153 | + | if s, _ := c.screenOf(1); s != b { | |||
| 154 | + | t.Fatal("window not on B") | |||
| 155 | + | } | |||
| 156 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 500, Height: 300}) | |||
| 157 | + | if b.mon.Current().fullscreen != 1 { | |||
| 158 | + | t.Fatal("window sized to its output B did not go fullscreen") | |||
| 159 | + | } | |||
| 160 | + | }) | |||
| 161 | + | t.Run("sized to another output", func(t *testing.T) { | |||
| 162 | + | c, b := second(t) | |||
| 163 | + | c.mapWindow(ports.WindowMapped{ID: 1}) | |||
| 164 | + | c.resizeFloating(ports.WindowResized{ID: 1, Width: 300, Height: 200}) | |||
| 165 | + | if b.mon.Current().fullscreen != 0 { | |||
| 166 | + | t.Fatal("window sized to output A went fullscreen on B") | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | } | |||
M internal/core/transition.go +35 -0
| @@ -518,6 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) { | |||
| 518 | 518 | old = settledRect(sc.mon, v.ID) | |
| 519 | 519 | } | |
| 520 | 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 521 | + | if c.legacyFullscreen(sc, v) { | |
| 522 | + | return | |
| 523 | + | } | |
| 521 | 524 | if !running { | |
| 522 | 525 | return | |
| 523 | 526 | } | |
| @@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot { | |||
| 981 | 984 | } | |
| 982 | 985 | return nil | |
| 983 | 986 | } | |
| 987 | + | ||
| 988 | + | // legacyFullscreen puts a window in the fullscreen state when the first size | |
| 989 | + | // it commits after mapping is exactly the logical size of the output it is | |
| 990 | + | // on: a game in borderless-windowed mode (X11 through xwayland-satellite) | |
| 991 | + | // never asks for fullscreen. It is the state toggle-fullscreen sets, and the | |
| 992 | + | // window is not resized. It follows the focus rules (ADR 011): only the | |
| 993 | + | // window that already has the focus on the shown workspace goes fullscreen, | |
| 994 | + | // so a fullscreen cover or another workspace is never disturbed. The parent | |
| 995 | + | // is the one read at map (a later set_parent is not excluded); remaps and | |
| 996 | + | // later resizes are left alone. It reports whether the window went | |
| 997 | + | // fullscreen. | |
| 998 | + | func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool { | |
| 999 | + | if !c.windows.firstSize(v.ID) { | |
| 1000 | + | return false | |
| 1001 | + | } | |
| 1002 | + | if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap { | |
| 1003 | + | return false | |
| 1004 | + | } | |
| 1005 | + | if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 { | |
| 1006 | + | return false | |
| 1007 | + | } | |
| 1008 | + | cur := sc.mon.Current() | |
| 1009 | + | if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) { | |
| 1010 | + | return false | |
| 1011 | + | } | |
| 1012 | + | sc.mon.SetFullscreen(v.ID, true) | |
| 1013 | + | if cur.fullscreen != v.ID { | |
| 1014 | + | return false | |
| 1015 | + | } | |
| 1016 | + | delete(sc.rects, v.ID) | |
| 1017 | + | return true | |
| 1018 | + | } | |
| @@ -518,6 +518,9 @@ func (c *Core) resizeFloating(v ports.WindowResized) { | |||||
| 518 | old = settledRect(sc.mon, v.ID) | 518 | old = settledRect(sc.mon, v.ID) | ||
| 519 | } | 519 | } | ||
| 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | 520 | w.ResizeFloating(v.ID, v.Width, v.Height) | ||
| 521 | + | if c.legacyFullscreen(sc, v) { | |||
| 522 | + | return | |||
| 523 | + | } | |||
| 521 | if !running { | 524 | if !running { | ||
| 522 | return | 525 | return | ||
| 523 | } | 526 | } | ||
| @@ -981,3 +984,35 @@ func movedFrom(b *viewShot, before []viewShot) *viewShot { | |||||
| 981 | } | 984 | } | ||
| 982 | return nil | 985 | return nil | ||
| 983 | } | 986 | } | ||
| 987 | + | ||||
| 988 | + | // legacyFullscreen puts a window in the fullscreen state when the first size | |||
| 989 | + | // it commits after mapping is exactly the logical size of the output it is | |||
| 990 | + | // on: a game in borderless-windowed mode (X11 through xwayland-satellite) | |||
| 991 | + | // never asks for fullscreen. It is the state toggle-fullscreen sets, and the | |||
| 992 | + | // window is not resized. It follows the focus rules (ADR 011): only the | |||
| 993 | + | // window that already has the focus on the shown workspace goes fullscreen, | |||
| 994 | + | // so a fullscreen cover or another workspace is never disturbed. The parent | |||
| 995 | + | // is the one read at map (a later set_parent is not excluded); remaps and | |||
| 996 | + | // later resizes are left alone. It reports whether the window went | |||
| 997 | + | // fullscreen. | |||
| 998 | + | func (c *Core) legacyFullscreen(sc *screen, v ports.WindowResized) bool { | |||
| 999 | + | if !c.windows.firstSize(v.ID) { | |||
| 1000 | + | return false | |||
| 1001 | + | } | |||
| 1002 | + | if rec := c.windows.lookup(v.ID); rec.client.Parent != 0 || rec.client.Remap { | |||
| 1003 | + | return false | |||
| 1004 | + | } | |||
| 1005 | + | if r := sc.rect(); v.Width != r.W || v.Height != r.H || r.W <= 0 { | |||
| 1006 | + | return false | |||
| 1007 | + | } | |||
| 1008 | + | cur := sc.mon.Current() | |||
| 1009 | + | if id, ok := cur.Focused(); !ok || id != v.ID || !cur.has(v.ID) { | |||
| 1010 | + | return false | |||
| 1011 | + | } | |||
| 1012 | + | sc.mon.SetFullscreen(v.ID, true) | |||
| 1013 | + | if cur.fullscreen != v.ID { | |||
| 1014 | + | return false | |||
| 1015 | + | } | |||
| 1016 | + | delete(sc.rects, v.ID) | |||
| 1017 | + | return true | |||
| 1018 | + | } | |||
M internal/core/windows.go +14 -1
| @@ -12,6 +12,8 @@ type windowRecord struct { | |||
| 12 | 12 | // client holds the app ID and PID of a mapped window, for State. | |
| 13 | 13 | client ports.WindowMapped | |
| 14 | 14 | mappedAt time.Time | |
| 15 | + | // sized is set once the window's first committed size was seen. | |
| 16 | + | sized bool | |
| 15 | 17 | // region is the input region; nil accepts input everywhere. | |
| 16 | 18 | region *ports.InputRegionChanged | |
| 17 | 19 | // inhibitShortcuts asks to keep compositor binds while focused; | |
| @@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord { | |||
| 50 | 52 | ||
| 51 | 53 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | |
| 52 | 54 | rec := r.at(v.ID) | |
| 53 | - | rec.client, rec.mappedAt = v, now | |
| 55 | + | rec.client, rec.mappedAt, rec.sized = v, now, false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // firstSize reports whether this is the first committed size of the window | |
| 59 | + | // since it mapped, and records that it was seen. | |
| 60 | + | func (r *windowRegistry) firstSize(id WindowID) bool { | |
| 61 | + | rec := r.records[id] | |
| 62 | + | if rec == nil || rec.sized || rec.client.ID == 0 { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | rec.sized = true | |
| 66 | + | return true | |
| 54 | 67 | } | |
| 55 | 68 | ||
| 56 | 69 | // setAppID updates the app ID of a mapped window only. | |
| @@ -12,6 +12,8 @@ type windowRecord struct { | |||||
| 12 | // client holds the app ID and PID of a mapped window, for State. | 12 | // client holds the app ID and PID of a mapped window, for State. | ||
| 13 | client ports.WindowMapped | 13 | client ports.WindowMapped | ||
| 14 | mappedAt time.Time | 14 | mappedAt time.Time | ||
| 15 | + | // sized is set once the window's first committed size was seen. | |||
| 16 | + | sized bool | |||
| 15 | // region is the input region; nil accepts input everywhere. | 17 | // region is the input region; nil accepts input everywhere. | ||
| 16 | region *ports.InputRegionChanged | 18 | region *ports.InputRegionChanged | ||
| 17 | // inhibitShortcuts asks to keep compositor binds while focused; | 19 | // inhibitShortcuts asks to keep compositor binds while focused; | ||
| @@ -50,7 +52,18 @@ func (r *windowRegistry) lookup(id WindowID) windowRecord { | |||||
| 50 | 52 | ||||
| 51 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | 53 | func (r *windowRegistry) mapped(v ports.WindowMapped, now time.Time) { | ||
| 52 | rec := r.at(v.ID) | 54 | rec := r.at(v.ID) | ||
| 53 | - | rec.client, rec.mappedAt = v, now | 55 | + | rec.client, rec.mappedAt, rec.sized = v, now, false |
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // firstSize reports whether this is the first committed size of the window | |||
| 59 | + | // since it mapped, and records that it was seen. | |||
| 60 | + | func (r *windowRegistry) firstSize(id WindowID) bool { | |||
| 61 | + | rec := r.records[id] | |||
| 62 | + | if rec == nil || rec.sized || rec.client.ID == 0 { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | rec.sized = true | |||
| 66 | + | return true | |||
| 54 | } | 67 | } | ||
| 55 | 68 | ||||
| 56 | // setAppID updates the app ID of a mapped window only. | 69 | // setAppID updates the app ID of a mapped window only. | ||