fix(shell): keep floating dialogs above fullscreen windows and nest popup grabs
- commit
- 0da7456c5836f647d92843d07957d2d5a9eee689
- author
- brice · · 2026-09-25 14:32 UTC
- parent
- 5fe516e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +37 -21
M internal/adapters/wayland/popup.go +18 -2
| @@ -97,8 +97,8 @@ func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |||
| 97 | 97 | // Only a grab answering the client's last press may take the keyboard, | |
| 98 | 98 | // and a popup over a popup grabs only if its parent does. | |
| 99 | 99 | s := p.w.xdg.server | |
| 100 | - | fresh := serial == s.press && s.pressClient != nil && *s.pressClient == r.Client() | |
| 101 | - | if !fresh || p.parent == nil || p.parent.popup != nil && !p.parent.popup.grab { | |
| 100 | + | fresh := serial == s.press && s.pressClient == r.Client() | |
| 101 | + | if !fresh || p.parent == nil || !s.topGrab(p.parent) { | |
| 102 | 102 | p.dismiss() | |
| 103 | 103 | return | |
| 104 | 104 | } | |
| @@ -137,6 +137,22 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | + | // topGrab reports whether a grabbing popup may go on parent: parent is | |
| 141 | + | // the client's newest live grabbing popup, or a toplevel when it has none. | |
| 142 | + | func (s *Server) topGrab(parent *window) bool { | |
| 143 | + | client := parent.xdg.resource.Client() | |
| 144 | + | var top *window | |
| 145 | + | for _, w := range s.windows { | |
| 146 | + | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | |
| 147 | + | top = w | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | if top == nil { | |
| 151 | + | return parent.popup == nil | |
| 152 | + | } | |
| 153 | + | return top == parent | |
| 154 | + | } | |
| 155 | + | ||
| 140 | 156 | // dismiss sends popup_done once; the client then destroys the popup. | |
| 141 | 157 | func (p *popup) dismiss() { | |
| 142 | 158 | if p.done { | |
| @@ -97,8 +97,8 @@ func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |||||
| 97 | // Only a grab answering the client's last press may take the keyboard, | 97 | // Only a grab answering the client's last press may take the keyboard, | ||
| 98 | // and a popup over a popup grabs only if its parent does. | 98 | // and a popup over a popup grabs only if its parent does. | ||
| 99 | s := p.w.xdg.server | 99 | s := p.w.xdg.server | ||
| 100 | - | fresh := serial == s.press && s.pressClient != nil && *s.pressClient == r.Client() | 100 | + | fresh := serial == s.press && s.pressClient == r.Client() |
| 101 | - | if !fresh || p.parent == nil || p.parent.popup != nil && !p.parent.popup.grab { | 101 | + | if !fresh || p.parent == nil || !s.topGrab(p.parent) { |
| 102 | p.dismiss() | 102 | p.dismiss() | ||
| 103 | return | 103 | return | ||
| 104 | } | 104 | } | ||
| @@ -137,6 +137,22 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||||
| 137 | } | 137 | } | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | + | // topGrab reports whether a grabbing popup may go on parent: parent is | |||
| 141 | + | // the client's newest live grabbing popup, or a toplevel when it has none. | |||
| 142 | + | func (s *Server) topGrab(parent *window) bool { | |||
| 143 | + | client := parent.xdg.resource.Client() | |||
| 144 | + | var top *window | |||
| 145 | + | for _, w := range s.windows { | |||
| 146 | + | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | |||
| 147 | + | top = w | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if top == nil { | |||
| 151 | + | return parent.popup == nil | |||
| 152 | + | } | |||
| 153 | + | return top == parent | |||
| 154 | + | } | |||
| 155 | + | ||||
| 140 | // dismiss sends popup_done once; the client then destroys the popup. | 156 | // dismiss sends popup_done once; the client then destroys the popup. | ||
| 141 | func (p *popup) dismiss() { | 157 | func (p *popup) dismiss() { | ||
| 142 | if p.done { | 158 | if p.done { | ||
M internal/adapters/wayland/server.go +8 -12
| @@ -54,7 +54,7 @@ type Server struct { | |||
| 54 | 54 | // press is the serial of the last button or key press, sent to | |
| 55 | 55 | // pressClient: popup grabs must come from it. | |
| 56 | 56 | press uint32 | |
| 57 | - | pressClient *server.Client | |
| 57 | + | pressClient server.Client | |
| 58 | 58 | ctx context.Context | |
| 59 | 59 | windows map[ports.WindowID]*window | |
| 60 | 60 | layers map[ports.WindowID]*layerSurface | |
| @@ -395,16 +395,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 395 | 395 | case ports.PointerButtonTo: | |
| 396 | 396 | // Releases may target the implicit-grab window after focus has moved. | |
| 397 | 397 | if w := s.windows[c.ID]; w != nil { | |
| 398 | + | s.serial++ | |
| 399 | + | state := uint32(0) | |
| 400 | + | if c.Pressed { | |
| 401 | + | state = 1 | |
| 402 | + | s.press, s.pressClient = s.serial, w.xdg.resource.Client() | |
| 403 | + | } | |
| 398 | 404 | for _, p := range s.windowPointers(w) { | |
| 399 | - | s.serial++ | |
| 400 | - | state := uint32(0) | |
| 401 | - | if c.Pressed { | |
| 402 | - | state = 1 | |
| 403 | - | } | |
| 404 | - | if c.Pressed { | |
| 405 | - | client := w.xdg.resource.Client() | |
| 406 | - | s.press, s.pressClient = s.serial, &client | |
| 407 | - | } | |
| 408 | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| 409 | 406 | pointerFrame(p) | |
| 410 | 407 | } | |
| @@ -436,8 +433,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 436 | 433 | } | |
| 437 | 434 | if c.Key.Pressed { | |
| 438 | 435 | if surf, _ := s.focusTarget(c.ID); surf != nil { | |
| 439 | - | client := surf.Client() | |
| 440 | - | s.press, s.pressClient = s.serial, &client | |
| 436 | + | s.press, s.pressClient = s.serial, surf.Client() | |
| 441 | 437 | } | |
| 442 | 438 | } | |
| 443 | 439 | for _, k := range keyboards { | |
| @@ -54,7 +54,7 @@ type Server struct { | |||||
| 54 | // press is the serial of the last button or key press, sent to | 54 | // press is the serial of the last button or key press, sent to | ||
| 55 | // pressClient: popup grabs must come from it. | 55 | // pressClient: popup grabs must come from it. | ||
| 56 | press uint32 | 56 | press uint32 | ||
| 57 | - | pressClient *server.Client | 57 | + | pressClient server.Client |
| 58 | ctx context.Context | 58 | ctx context.Context | ||
| 59 | windows map[ports.WindowID]*window | 59 | windows map[ports.WindowID]*window | ||
| 60 | layers map[ports.WindowID]*layerSurface | 60 | layers map[ports.WindowID]*layerSurface | ||
| @@ -395,16 +395,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 395 | case ports.PointerButtonTo: | 395 | case ports.PointerButtonTo: | ||
| 396 | // Releases may target the implicit-grab window after focus has moved. | 396 | // Releases may target the implicit-grab window after focus has moved. | ||
| 397 | if w := s.windows[c.ID]; w != nil { | 397 | if w := s.windows[c.ID]; w != nil { | ||
| 398 | + | s.serial++ | |||
| 399 | + | state := uint32(0) | |||
| 400 | + | if c.Pressed { | |||
| 401 | + | state = 1 | |||
| 402 | + | s.press, s.pressClient = s.serial, w.xdg.resource.Client() | |||
| 403 | + | } | |||
| 398 | for _, p := range s.windowPointers(w) { | 404 | for _, p := range s.windowPointers(w) { | ||
| 399 | - | s.serial++ | |||
| 400 | - | state := uint32(0) | |||
| 401 | - | if c.Pressed { | |||
| 402 | - | state = 1 | |||
| 403 | - | } | |||
| 404 | - | if c.Pressed { | |||
| 405 | - | client := w.xdg.resource.Client() | |||
| 406 | - | s.press, s.pressClient = s.serial, &client | |||
| 407 | - | } | |||
| 408 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | ||
| 409 | pointerFrame(p) | 406 | pointerFrame(p) | ||
| 410 | } | 407 | } | ||
| @@ -436,8 +433,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 436 | } | 433 | } | ||
| 437 | if c.Key.Pressed { | 434 | if c.Key.Pressed { | ||
| 438 | if surf, _ := s.focusTarget(c.ID); surf != nil { | 435 | if surf, _ := s.focusTarget(c.ID); surf != nil { | ||
| 439 | - | client := surf.Client() | 436 | + | s.press, s.pressClient = s.serial, surf.Client() |
| 440 | - | s.press, s.pressClient = s.serial, &client | |||
| 441 | } | 437 | } | ||
| 442 | } | 438 | } | ||
| 443 | for _, k := range keyboards { | 439 | for _, k := range keyboards { | ||
M internal/core/layout.go +3 -7
| @@ -195,10 +195,7 @@ func (w *Workspace) has(id WindowID) bool { | |||
| 195 | 195 | ||
| 196 | 196 | func (w *Workspace) Focused() (WindowID, bool) { | |
| 197 | 197 | if w.floatFocus && len(w.Floats) > 0 { | |
| 198 | - | // A fullscreen column window hides the floats: it has the focus. | |
| 199 | - | if top := w.Floats[len(w.Floats)-1].ID; w.fullscreen == 0 || w.fullscreen == top { | |
| 200 | - | return top, true | |
| 201 | - | } | |
| 198 | + | return w.Floats[len(w.Floats)-1].ID, true | |
| 202 | 199 | } | |
| 203 | 200 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| 204 | 201 | return 0, false | |
| @@ -642,11 +639,10 @@ func (w *Workspace) Layout() []Placement { | |||
| 642 | 639 | // Floating windows go last: they are drawn and hit on top. | |
| 643 | 640 | for _, f := range w.Floats { | |
| 644 | 641 | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | |
| 642 | + | // Floats stay above a fullscreen window: a dialog opened from a | |
| 643 | + | // fullscreen app must be seen. | |
| 645 | 644 | if w.fullscreen == f.ID { | |
| 646 | 645 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 647 | - | } else if w.fullscreen != 0 { | |
| 648 | - | // A fullscreen window covers the floating ones. | |
| 649 | - | p.Rect, p.Hidden = Rect{}, true | |
| 650 | 646 | } | |
| 651 | 647 | result = append(result, p) | |
| 652 | 648 | } | |
| @@ -195,10 +195,7 @@ func (w *Workspace) has(id WindowID) bool { | |||||
| 195 | 195 | ||||
| 196 | func (w *Workspace) Focused() (WindowID, bool) { | 196 | func (w *Workspace) Focused() (WindowID, bool) { | ||
| 197 | if w.floatFocus && len(w.Floats) > 0 { | 197 | if w.floatFocus && len(w.Floats) > 0 { | ||
| 198 | - | // A fullscreen column window hides the floats: it has the focus. | 198 | + | return w.Floats[len(w.Floats)-1].ID, true |
| 199 | - | if top := w.Floats[len(w.Floats)-1].ID; w.fullscreen == 0 || w.fullscreen == top { | |||
| 200 | - | return top, true | |||
| 201 | - | } | |||
| 202 | } | 199 | } | ||
| 203 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | 200 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | ||
| 204 | return 0, false | 201 | return 0, false | ||
| @@ -642,11 +639,10 @@ func (w *Workspace) Layout() []Placement { | |||||
| 642 | // Floating windows go last: they are drawn and hit on top. | 639 | // Floating windows go last: they are drawn and hit on top. | ||
| 643 | for _, f := range w.Floats { | 640 | for _, f := range w.Floats { | ||
| 644 | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | 641 | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | ||
| 642 | + | // Floats stay above a fullscreen window: a dialog opened from a | |||
| 643 | + | // fullscreen app must be seen. | |||
| 645 | if w.fullscreen == f.ID { | 644 | if w.fullscreen == f.ID { | ||
| 646 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | 645 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | ||
| 647 | - | } else if w.fullscreen != 0 { | |||
| 648 | - | // A fullscreen window covers the floating ones. | |||
| 649 | - | p.Rect, p.Hidden = Rect{}, true | |||
| 650 | } | 646 | } | ||
| 651 | result = append(result, p) | 647 | result = append(result, p) | ||
| 652 | } | 648 | } | ||
M internal/core/popup_test.go +8 -0
| @@ -55,6 +55,14 @@ func TestFloatingWindows(t *testing.T) { | |||
| 55 | 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | |
| 56 | 56 | t.Fatalf("clamped %+v", r) | |
| 57 | 57 | } | |
| 58 | + | // A dialog from a fullscreen window shows above it and takes the focus. | |
| 59 | + | w.SetFullscreen(1, true) | |
| 60 | + | w.RemoveWindow(2) | |
| 61 | + | w.AddFloating(2, 20, 10) | |
| 62 | + | if l := w.Layout(); l[1].Hidden || !l[1].Focused || l[0].Focused { | |
| 63 | + | t.Fatalf("float under fullscreen %+v", l) | |
| 64 | + | } | |
| 65 | + | w.SetFullscreen(1, false) | |
| 58 | 66 | w.RemoveWindow(2) | |
| 59 | 67 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | |
| 60 | 68 | t.Fatalf("focus after close %d", id) | |
| @@ -55,6 +55,14 @@ func TestFloatingWindows(t *testing.T) { | |||||
| 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | 55 | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | ||
| 56 | t.Fatalf("clamped %+v", r) | 56 | t.Fatalf("clamped %+v", r) | ||
| 57 | } | 57 | } | ||
| 58 | + | // A dialog from a fullscreen window shows above it and takes the focus. | |||
| 59 | + | w.SetFullscreen(1, true) | |||
| 60 | + | w.RemoveWindow(2) | |||
| 61 | + | w.AddFloating(2, 20, 10) | |||
| 62 | + | if l := w.Layout(); l[1].Hidden || !l[1].Focused || l[0].Focused { | |||
| 63 | + | t.Fatalf("float under fullscreen %+v", l) | |||
| 64 | + | } | |||
| 65 | + | w.SetFullscreen(1, false) | |||
| 58 | w.RemoveWindow(2) | 66 | w.RemoveWindow(2) | ||
| 59 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | 67 | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | ||
| 60 | t.Fatalf("focus after close %d", id) | 68 | t.Fatalf("focus after close %d", id) | ||