fix(core): apply taskbar fullscreen requests during the map grace
Foreign-toplevel requests come from the user, not the window: WindowFullscreenRequest.External skips fullscreenGrace. The map test now waits for the dialog in the scene.
- commit
- 9f2331720e1c1ad05b24d3e0bae5a8e2fbfb2d5b
- author
- brice · · 2026-09-28 15:37 UTC
- parent
- f94690a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +39 -6
M internal/adapters/wayland/foreigntoplevel.go +2 -2
| @@ -246,11 +246,11 @@ func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *w | |||
| 246 | 246 | } | |
| 247 | 247 | func (t *toplevelRequests) SetFullscreen(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Output) { | |
| 248 | 248 | if w := t.window(); w != nil { | |
| 249 | - | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | |
| 249 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true, External: true}) | |
| 250 | 250 | } | |
| 251 | 251 | } | |
| 252 | 252 | func (t *toplevelRequests) UnsetFullscreen(*wlr.ZwlrForeignToplevelHandleV1) { | |
| 253 | 253 | if w := t.window(); w != nil { | |
| 254 | - | t.s.emit(ports.WindowFullscreenRequest{ID: w.id}) | |
| 254 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, External: true}) | |
| 255 | 255 | } | |
| 256 | 256 | } | |
| @@ -246,11 +246,11 @@ func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *w | |||||
| 246 | } | 246 | } | ||
| 247 | func (t *toplevelRequests) SetFullscreen(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Output) { | 247 | func (t *toplevelRequests) SetFullscreen(*wlr.ZwlrForeignToplevelHandleV1, *wayland.Output) { | ||
| 248 | if w := t.window(); w != nil { | 248 | if w := t.window(); w != nil { | ||
| 249 | - | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true}) | 249 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true, External: true}) |
| 250 | } | 250 | } | ||
| 251 | } | 251 | } | ||
| 252 | func (t *toplevelRequests) UnsetFullscreen(*wlr.ZwlrForeignToplevelHandleV1) { | 252 | func (t *toplevelRequests) UnsetFullscreen(*wlr.ZwlrForeignToplevelHandleV1) { | ||
| 253 | if w := t.window(); w != nil { | 253 | if w := t.window(); w != nil { | ||
| 254 | - | t.s.emit(ports.WindowFullscreenRequest{ID: w.id}) | 254 | + | t.s.emit(ports.WindowFullscreenRequest{ID: w.id, External: true}) |
| 255 | } | 255 | } | ||
| 256 | } | 256 | } | ||
M internal/adapters/wayland/foreigntoplevel_test.go +2 -2
| @@ -129,11 +129,11 @@ func TestForeignToplevel(t *testing.T) { | |||
| 129 | 129 | ||
| 130 | 130 | // Requests reach core as the xdg-shell and xdg-activation messages. | |
| 131 | 131 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | |
| 132 | - | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true}) { | |
| 132 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true, External: true}) { | |
| 133 | 133 | t.Fatalf("set_fullscreen = %#v", ev) | |
| 134 | 134 | } | |
| 135 | 135 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestUnsetFullscreen) | |
| 136 | - | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID}) { | |
| 136 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, External: true}) { | |
| 137 | 137 | t.Fatalf("unset_fullscreen = %#v", ev) | |
| 138 | 138 | } | |
| 139 | 139 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | |
| @@ -129,11 +129,11 @@ func TestForeignToplevel(t *testing.T) { | |||||
| 129 | 129 | ||||
| 130 | // Requests reach core as the xdg-shell and xdg-activation messages. | 130 | // Requests reach core as the xdg-shell and xdg-activation messages. | ||
| 131 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | 131 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestSetFullscreen, uint32(0)) | ||
| 132 | - | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true}) { | 132 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, Fullscreen: true, External: true}) { |
| 133 | t.Fatalf("set_fullscreen = %#v", ev) | 133 | t.Fatalf("set_fullscreen = %#v", ev) | ||
| 134 | } | 134 | } | ||
| 135 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestUnsetFullscreen) | 135 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestUnsetFullscreen) | ||
| 136 | - | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID}) { | 136 | + | if ev := clientEvent[ports.WindowFullscreenRequest](t, events); ev != (ports.WindowFullscreenRequest{ID: w.ID, External: true}) { |
| 137 | t.Fatalf("unset_fullscreen = %#v", ev) | 137 | t.Fatalf("unset_fullscreen = %#v", ev) | ||
| 138 | } | 138 | } | ||
| 139 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | 139 | requestProtocol(t, c, handle, wlr.ZwlrForeignToplevelHandleV1RequestActivate, seat) | ||
M internal/core/core.go +2 -2
| @@ -51,7 +51,7 @@ type Channels struct { | |||
| 51 | 51 | // is ignored. Wine asks for fullscreen whenever a window has the monitor's | |
| 52 | 52 | // size, so a launcher that remembered a fullscreen size would open | |
| 53 | 53 | // fullscreen and hide the windows it opens next. The user can still | |
| 54 | - | // fullscreen it, and an app can ask again later. | |
| 54 | + | // fullscreen it (bind or taskbar), and an app can ask again later. | |
| 55 | 55 | const fullscreenGrace = time.Second | |
| 56 | 56 | type binding struct { | |
| 57 | 57 | mods ports.Mods | |
| @@ -788,7 +788,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 788 | 788 | case ports.PointerConstrained: | |
| 789 | 789 | c.constrained = v | |
| 790 | 790 | case ports.WindowFullscreenRequest: | |
| 791 | - | if v.Fullscreen && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { | |
| 791 | + | if v.Fullscreen && !v.External && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { | |
| 792 | 792 | continue | |
| 793 | 793 | } | |
| 794 | 794 | if s, _ := c.screenOf(v.ID); s != nil { | |
| @@ -51,7 +51,7 @@ type Channels struct { | |||||
| 51 | // is ignored. Wine asks for fullscreen whenever a window has the monitor's | 51 | // is ignored. Wine asks for fullscreen whenever a window has the monitor's | ||
| 52 | // size, so a launcher that remembered a fullscreen size would open | 52 | // size, so a launcher that remembered a fullscreen size would open | ||
| 53 | // fullscreen and hide the windows it opens next. The user can still | 53 | // fullscreen and hide the windows it opens next. The user can still | ||
| 54 | - | // fullscreen it, and an app can ask again later. | 54 | + | // fullscreen it (bind or taskbar), and an app can ask again later. |
| 55 | const fullscreenGrace = time.Second | 55 | const fullscreenGrace = time.Second | ||
| 56 | type binding struct { | 56 | type binding struct { | ||
| 57 | mods ports.Mods | 57 | mods ports.Mods | ||
| @@ -788,7 +788,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 788 | case ports.PointerConstrained: | 788 | case ports.PointerConstrained: | ||
| 789 | c.constrained = v | 789 | c.constrained = v | ||
| 790 | case ports.WindowFullscreenRequest: | 790 | case ports.WindowFullscreenRequest: | ||
| 791 | - | if v.Fullscreen && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { | 791 | + | if v.Fullscreen && !v.External && c.now().Sub(c.mappedAt[v.ID]) < fullscreenGrace { |
| 792 | continue | 792 | continue | ||
| 793 | } | 793 | } | ||
| 794 | if s, _ := c.screenOf(v.ID); s != nil { | 794 | if s, _ := c.screenOf(v.ID); s != nil { | ||
M internal/core/core_test.go +30 -0
| @@ -774,6 +774,9 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 774 | 774 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 775 | 775 | client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10} | |
| 776 | 776 | s := scene(t, scenes) | |
| 777 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) { | |
| 778 | + | s = scene(t, scenes) | |
| 779 | + | } | |
| 777 | 780 | for _, w := range s.Windows { | |
| 778 | 781 | if w.Fullscreen || w.Hidden { | |
| 779 | 782 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | |
| @@ -789,6 +792,33 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||
| 789 | 792 | } | |
| 790 | 793 | } | |
| 791 | 794 | ||
| 795 | + | // A taskbar's fullscreen request is the user's: it applies at once. | |
| 796 | + | func TestExternalFullscreenAtMapApplies(t *testing.T) { | |
| 797 | + | cfg := config.Defaults() | |
| 798 | + | client := make(chan ports.ClientEvent, 8) | |
| 799 | + | output := make(chan ports.OutputEvent, 8) | |
| 800 | + | commands := make(chan ports.ClientCommand, 64) | |
| 801 | + | scenes := make(chan []ports.Scene, 1) | |
| 802 | + | clock := portsmocks.NewMockClock(t) | |
| 803 | + | clock.EXPECT().Now().Return(time.Unix(0, 0)) | |
| 804 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |
| 805 | + | if err != nil { | |
| 806 | + | t.Fatal(err) | |
| 807 | + | } | |
| 808 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 809 | + | defer cancel() | |
| 810 | + | go c.Run(ctx) | |
| 811 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 812 | + | scene(t, scenes) | |
| 813 | + | client <- ports.WindowMapped{ID: 1} | |
| 814 | + | scene(t, scenes) | |
| 815 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 816 | + | s := scene(t, scenes) | |
| 817 | + | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | |
| 818 | + | t.Fatalf("taskbar fullscreen ignored: %+v", s.Windows) | |
| 819 | + | } | |
| 820 | + | } | |
| 821 | + | ||
| 792 | 822 | // An activated window on another workspace comes on screen with focus. | |
| 793 | 823 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | |
| 794 | 824 | cfg := config.Defaults() | |
| @@ -774,6 +774,9 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 774 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | 774 | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | ||
| 775 | client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10} | 775 | client <- ports.WindowMapped{ID: 3, Floating: true, Width: 20, Height: 10} | ||
| 776 | s := scene(t, scenes) | 776 | s := scene(t, scenes) | ||
| 777 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) { | |||
| 778 | + | s = scene(t, scenes) | |||
| 779 | + | } | |||
| 777 | for _, w := range s.Windows { | 780 | for _, w := range s.Windows { | ||
| 778 | if w.Fullscreen || w.Hidden { | 781 | if w.Fullscreen || w.Hidden { | ||
| 779 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | 782 | t.Fatalf("startup fullscreen applied: %+v", s.Windows) | ||
| @@ -789,6 +792,33 @@ func TestFullscreenAtMapIgnored(t *testing.T) { | |||||
| 789 | } | 792 | } | ||
| 790 | } | 793 | } | ||
| 791 | 794 | ||||
| 795 | + | // A taskbar's fullscreen request is the user's: it applies at once. | |||
| 796 | + | func TestExternalFullscreenAtMapApplies(t *testing.T) { | |||
| 797 | + | cfg := config.Defaults() | |||
| 798 | + | client := make(chan ports.ClientEvent, 8) | |||
| 799 | + | output := make(chan ports.OutputEvent, 8) | |||
| 800 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 801 | + | scenes := make(chan []ports.Scene, 1) | |||
| 802 | + | clock := portsmocks.NewMockClock(t) | |||
| 803 | + | clock.EXPECT().Now().Return(time.Unix(0, 0)) | |||
| 804 | + | c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: clock}) | |||
| 805 | + | if err != nil { | |||
| 806 | + | t.Fatal(err) | |||
| 807 | + | } | |||
| 808 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 809 | + | defer cancel() | |||
| 810 | + | go c.Run(ctx) | |||
| 811 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 812 | + | scene(t, scenes) | |||
| 813 | + | client <- ports.WindowMapped{ID: 1} | |||
| 814 | + | scene(t, scenes) | |||
| 815 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |||
| 816 | + | s := scene(t, scenes) | |||
| 817 | + | if len(s.Windows) != 1 || !s.Windows[0].Fullscreen { | |||
| 818 | + | t.Fatalf("taskbar fullscreen ignored: %+v", s.Windows) | |||
| 819 | + | } | |||
| 820 | + | } | |||
| 821 | + | ||||
| 792 | // An activated window on another workspace comes on screen with focus. | 822 | // An activated window on another workspace comes on screen with focus. | ||
| 793 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | 823 | func TestWindowActivateShowsAndFocuses(t *testing.T) { | ||
| 794 | cfg := config.Defaults() | 824 | cfg := config.Defaults() | ||
M internal/ports/messages.go +3 -0
| @@ -94,9 +94,12 @@ type OutputPower struct { | |||
| 94 | 94 | func (OutputPower) clientEvent() {} | |
| 95 | 95 | ||
| 96 | 96 | // WindowFullscreenRequest carries wayland → core fullscreen requests. | |
| 97 | + | // External is set when another client (a taskbar, through foreign | |
| 98 | + | // toplevel) asks for it, not the window itself. | |
| 97 | 99 | type WindowFullscreenRequest struct { | |
| 98 | 100 | ID WindowID | |
| 99 | 101 | Fullscreen bool | |
| 102 | + | External bool | |
| 100 | 103 | } | |
| 101 | 104 | ||
| 102 | 105 | func (WindowFullscreenRequest) clientEvent() {} | |
| @@ -94,9 +94,12 @@ type OutputPower struct { | |||||
| 94 | func (OutputPower) clientEvent() {} | 94 | func (OutputPower) clientEvent() {} | ||
| 95 | 95 | ||||
| 96 | // WindowFullscreenRequest carries wayland → core fullscreen requests. | 96 | // WindowFullscreenRequest carries wayland → core fullscreen requests. | ||
| 97 | + | // External is set when another client (a taskbar, through foreign | |||
| 98 | + | // toplevel) asks for it, not the window itself. | |||
| 97 | type WindowFullscreenRequest struct { | 99 | type WindowFullscreenRequest struct { | ||
| 98 | ID WindowID | 100 | ID WindowID | ||
| 99 | Fullscreen bool | 101 | Fullscreen bool | ||
| 102 | + | External bool | |||
| 100 | } | 103 | } | ||
| 101 | 104 | ||||
| 102 | func (WindowFullscreenRequest) clientEvent() {} | 105 | func (WindowFullscreenRequest) clientEvent() {} | ||