fix(shell): close popups topmost first and check popup grab serials
- commit
- 5fe516ef79f422923dcaeaaa8abb26a8338583c9
- author
- brice · · 2026-09-25 14:28 UTC
- parent
- 6bc6748
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +148 -58
M internal/adapters/wayland/popup.go +12 -2
| @@ -63,12 +63,14 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||
| 63 | 63 | s.emit(ports.PopupMapped{ID: w.id}) | |
| 64 | 64 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(parent.id)).Msg("popup mapped") | |
| 65 | 65 | case !buffer && w.mapped: | |
| 66 | + | // Unmapped, it may map again after a new initial commit. | |
| 66 | 67 | w.unmap() | |
| 68 | + | pp.sent, pp.grab = false, false | |
| 67 | 69 | } | |
| 68 | 70 | } | |
| 69 | 71 | res.OnDestroy = func() { | |
| 70 | 72 | // Core forgets the popup, placed or mapped. | |
| 71 | - | if !w.mapped && pp.sent { | |
| 73 | + | if !w.mapped && pp.sent && !pp.done { | |
| 72 | 74 | s.emit(ports.WindowUnmapped{ID: w.id}) | |
| 73 | 75 | } | |
| 74 | 76 | w.unmap() | |
| @@ -87,11 +89,19 @@ func (*popup) Destroy(*xdgshell.Popup) {} | |||
| 87 | 89 | ||
| 88 | 90 | // Grab is only valid before the first commit; core then gives the popup the | |
| 89 | 91 | // keyboard and closes it on a click outside its popup chain. | |
| 90 | - | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, _ uint32) { | |
| 92 | + | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |
| 91 | 93 | if p.sent || p.w.mapped { | |
| 92 | 94 | r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed") | |
| 93 | 95 | return | |
| 94 | 96 | } | |
| 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. | |
| 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 { | |
| 102 | + | p.dismiss() | |
| 103 | + | return | |
| 104 | + | } | |
| 95 | 105 | p.grab = true | |
| 96 | 106 | } | |
| 97 | 107 | ||
| @@ -63,12 +63,14 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 63 | s.emit(ports.PopupMapped{ID: w.id}) | 63 | s.emit(ports.PopupMapped{ID: w.id}) | ||
| 64 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(parent.id)).Msg("popup mapped") | 64 | s.log.Debug().Uint64("id", uint64(w.id)).Uint64("parent", uint64(parent.id)).Msg("popup mapped") | ||
| 65 | case !buffer && w.mapped: | 65 | case !buffer && w.mapped: | ||
| 66 | + | // Unmapped, it may map again after a new initial commit. | |||
| 66 | w.unmap() | 67 | w.unmap() | ||
| 68 | + | pp.sent, pp.grab = false, false | |||
| 67 | } | 69 | } | ||
| 68 | } | 70 | } | ||
| 69 | res.OnDestroy = func() { | 71 | res.OnDestroy = func() { | ||
| 70 | // Core forgets the popup, placed or mapped. | 72 | // Core forgets the popup, placed or mapped. | ||
| 71 | - | if !w.mapped && pp.sent { | 73 | + | if !w.mapped && pp.sent && !pp.done { |
| 72 | s.emit(ports.WindowUnmapped{ID: w.id}) | 74 | s.emit(ports.WindowUnmapped{ID: w.id}) | ||
| 73 | } | 75 | } | ||
| 74 | w.unmap() | 76 | w.unmap() | ||
| @@ -87,11 +89,19 @@ func (*popup) Destroy(*xdgshell.Popup) {} | |||||
| 87 | 89 | ||||
| 88 | // Grab is only valid before the first commit; core then gives the popup the | 90 | // Grab is only valid before the first commit; core then gives the popup the | ||
| 89 | // keyboard and closes it on a click outside its popup chain. | 91 | // keyboard and closes it on a click outside its popup chain. | ||
| 90 | - | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, _ uint32) { | 92 | + | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { |
| 91 | if p.sent || p.w.mapped { | 93 | if p.sent || p.w.mapped { | ||
| 92 | r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed") | 94 | r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed") | ||
| 93 | return | 95 | return | ||
| 94 | } | 96 | } | ||
| 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. | |||
| 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 { | |||
| 102 | + | p.dismiss() | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 95 | p.grab = true | 105 | p.grab = true | ||
| 96 | } | 106 | } | ||
| 97 | 107 | ||||
M internal/adapters/wayland/server.go +28 -14
| @@ -37,20 +37,24 @@ type Channels struct { | |||
| 37 | 37 | Cursors chan<- ports.CursorChange | |
| 38 | 38 | } | |
| 39 | 39 | type Server struct { | |
| 40 | - | display *server.Display | |
| 41 | - | env procEnv | |
| 42 | - | slotsPending bool // core waits for a slot window | |
| 43 | - | name string | |
| 44 | - | cleanup func() | |
| 45 | - | log zerowrap.Logger | |
| 46 | - | channels Channels | |
| 47 | - | awaiting []*wayland.Callback | |
| 48 | - | frames uint64 | |
| 49 | - | started time.Time | |
| 50 | - | surfaces map[*server.Resource]*surface | |
| 51 | - | buffers map[*server.Resource]clientBuffer | |
| 52 | - | dmabuf *dmabufGlobal | |
| 53 | - | serial uint32 | |
| 40 | + | display *server.Display | |
| 41 | + | env procEnv | |
| 42 | + | slotsPending bool // core waits for a slot window | |
| 43 | + | name string | |
| 44 | + | cleanup func() | |
| 45 | + | log zerowrap.Logger | |
| 46 | + | channels Channels | |
| 47 | + | awaiting []*wayland.Callback | |
| 48 | + | frames uint64 | |
| 49 | + | started time.Time | |
| 50 | + | surfaces map[*server.Resource]*surface | |
| 51 | + | buffers map[*server.Resource]clientBuffer | |
| 52 | + | dmabuf *dmabufGlobal | |
| 53 | + | serial uint32 | |
| 54 | + | // press is the serial of the last button or key press, sent to | |
| 55 | + | // pressClient: popup grabs must come from it. | |
| 56 | + | press uint32 | |
| 57 | + | pressClient *server.Client | |
| 54 | 58 | ctx context.Context | |
| 55 | 59 | windows map[ports.WindowID]*window | |
| 56 | 60 | layers map[ports.WindowID]*layerSurface | |
| @@ -397,6 +401,10 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 397 | 401 | if c.Pressed { | |
| 398 | 402 | state = 1 | |
| 399 | 403 | } | |
| 404 | + | if c.Pressed { | |
| 405 | + | client := w.xdg.resource.Client() | |
| 406 | + | s.press, s.pressClient = s.serial, &client | |
| 407 | + | } | |
| 400 | 408 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| 401 | 409 | pointerFrame(p) | |
| 402 | 410 | } | |
| @@ -426,6 +434,12 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 426 | 434 | if c.Key.Pressed { | |
| 427 | 435 | state = 1 | |
| 428 | 436 | } | |
| 437 | + | if c.Key.Pressed { | |
| 438 | + | if surf, _ := s.focusTarget(c.ID); surf != nil { | |
| 439 | + | client := surf.Client() | |
| 440 | + | s.press, s.pressClient = s.serial, &client | |
| 441 | + | } | |
| 442 | + | } | |
| 429 | 443 | for _, k := range keyboards { | |
| 430 | 444 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | |
| 431 | 445 | } | |
| @@ -37,20 +37,24 @@ type Channels struct { | |||||
| 37 | Cursors chan<- ports.CursorChange | 37 | Cursors chan<- ports.CursorChange | ||
| 38 | } | 38 | } | ||
| 39 | type Server struct { | 39 | type Server struct { | ||
| 40 | - | display *server.Display | 40 | + | display *server.Display |
| 41 | - | env procEnv | 41 | + | env procEnv |
| 42 | - | slotsPending bool // core waits for a slot window | 42 | + | slotsPending bool // core waits for a slot window |
| 43 | - | name string | 43 | + | name string |
| 44 | - | cleanup func() | 44 | + | cleanup func() |
| 45 | - | log zerowrap.Logger | 45 | + | log zerowrap.Logger |
| 46 | - | channels Channels | 46 | + | channels Channels |
| 47 | - | awaiting []*wayland.Callback | 47 | + | awaiting []*wayland.Callback |
| 48 | - | frames uint64 | 48 | + | frames uint64 |
| 49 | - | started time.Time | 49 | + | started time.Time |
| 50 | - | surfaces map[*server.Resource]*surface | 50 | + | surfaces map[*server.Resource]*surface |
| 51 | - | buffers map[*server.Resource]clientBuffer | 51 | + | buffers map[*server.Resource]clientBuffer |
| 52 | - | dmabuf *dmabufGlobal | 52 | + | dmabuf *dmabufGlobal |
| 53 | - | serial uint32 | 53 | + | serial uint32 |
| 54 | + | // press is the serial of the last button or key press, sent to | |||
| 55 | + | // pressClient: popup grabs must come from it. | |||
| 56 | + | press uint32 | |||
| 57 | + | pressClient *server.Client | |||
| 54 | ctx context.Context | 58 | ctx context.Context | ||
| 55 | windows map[ports.WindowID]*window | 59 | windows map[ports.WindowID]*window | ||
| 56 | layers map[ports.WindowID]*layerSurface | 60 | layers map[ports.WindowID]*layerSurface | ||
| @@ -397,6 +401,10 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 397 | if c.Pressed { | 401 | if c.Pressed { | ||
| 398 | state = 1 | 402 | state = 1 | ||
| 399 | } | 403 | } | ||
| 404 | + | if c.Pressed { | |||
| 405 | + | client := w.xdg.resource.Client() | |||
| 406 | + | s.press, s.pressClient = s.serial, &client | |||
| 407 | + | } | |||
| 400 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | 408 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | ||
| 401 | pointerFrame(p) | 409 | pointerFrame(p) | ||
| 402 | } | 410 | } | ||
| @@ -426,6 +434,12 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 426 | if c.Key.Pressed { | 434 | if c.Key.Pressed { | ||
| 427 | state = 1 | 435 | state = 1 | ||
| 428 | } | 436 | } | ||
| 437 | + | if c.Key.Pressed { | |||
| 438 | + | if surf, _ := s.focusTarget(c.ID); surf != nil { | |||
| 439 | + | client := surf.Client() | |||
| 440 | + | s.press, s.pressClient = s.serial, &client | |||
| 441 | + | } | |||
| 442 | + | } | |||
| 429 | for _, k := range keyboards { | 443 | for _, k := range keyboards { | ||
| 430 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | 444 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | ||
| 431 | } | 445 | } | ||
M internal/core/core.go +2 -2
| @@ -565,7 +565,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 565 | 565 | c.clients[v.ID] = v | |
| 566 | 566 | if v.Floating { | |
| 567 | 567 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 568 | - | c.cur().mon.Current().AddFloating(v.ID, v.Width, v.Height) | |
| 568 | + | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |
| 569 | 569 | } | |
| 570 | 570 | } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |
| 571 | 571 | if s, _ := c.screenOf(v.ID); s == nil { | |
| @@ -595,7 +595,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 595 | 595 | return nil | |
| 596 | 596 | } | |
| 597 | 597 | } | |
| 598 | - | if err := c.dropPopupsOf(ctx, v.ID); err != nil { | |
| 598 | + | if err := c.closePopupsOf(ctx, v.ID); err != nil { | |
| 599 | 599 | return nil | |
| 600 | 600 | } | |
| 601 | 601 | delete(c.clients, v.ID) | |
| @@ -565,7 +565,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 565 | c.clients[v.ID] = v | 565 | c.clients[v.ID] = v | ||
| 566 | if v.Floating { | 566 | if v.Floating { | ||
| 567 | if s, _ := c.screenOf(v.ID); s == nil { | 567 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 568 | - | c.cur().mon.Current().AddFloating(v.ID, v.Width, v.Height) | 568 | + | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) |
| 569 | } | 569 | } | ||
| 570 | } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | 570 | } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | ||
| 571 | if s, _ := c.screenOf(v.ID); s == nil { | 571 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| @@ -595,7 +595,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 595 | return nil | 595 | return nil | ||
| 596 | } | 596 | } | ||
| 597 | } | 597 | } | ||
| 598 | - | if err := c.dropPopupsOf(ctx, v.ID); err != nil { | 598 | + | if err := c.closePopupsOf(ctx, v.ID); err != nil { |
| 599 | return nil | 599 | return nil | ||
| 600 | } | 600 | } | ||
| 601 | delete(c.clients, v.ID) | 601 | delete(c.clients, v.ID) | ||
M internal/core/core_test.go +48 -0
| @@ -641,3 +641,51 @@ func TestPointerConstraint(t *testing.T) { | |||
| 641 | 641 | t.Fatalf("release %+v", got) | |
| 642 | 642 | } | |
| 643 | 643 | } | |
| 644 | + | ||
| 645 | + | // A click outside a grabbing menu chain closes it, the topmost popup first. | |
| 646 | + | func TestPopupChainDismissOrder(t *testing.T) { | |
| 647 | + | cfg := config.Defaults() | |
| 648 | + | cfg.Border.Width = 0 | |
| 649 | + | client := make(chan ports.ClientEvent, 8) | |
| 650 | + | input := make(chan ports.InputEvent, 8) | |
| 651 | + | output := make(chan ports.OutputEvent, 8) | |
| 652 | + | commands := make(chan ports.ClientCommand, 64) | |
| 653 | + | scenes := make(chan []ports.Scene, 1) | |
| 654 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 655 | + | if err != nil { | |
| 656 | + | t.Fatal(err) | |
| 657 | + | } | |
| 658 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 659 | + | defer cancel() | |
| 660 | + | go c.Run(ctx) | |
| 661 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 662 | + | scene(t, scenes) | |
| 663 | + | client <- ports.WindowMapped{ID: 1} | |
| 664 | + | scene(t, scenes) | |
| 665 | + | pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight} | |
| 666 | + | for _, v := range []struct{ id, parent ports.WindowID }{{2, 1}, {3, 2}} { | |
| 667 | + | client <- ports.PopupRequest{ID: v.id, Parent: v.parent, Positioner: pos, Grab: true} | |
| 668 | + | for { | |
| 669 | + | if cp, ok := command(t, commands).(ports.ConfigurePopup); ok && cp.ID == v.id { | |
| 670 | + | break | |
| 671 | + | } | |
| 672 | + | } | |
| 673 | + | client <- ports.PopupMapped{ID: v.id} | |
| 674 | + | scene(t, scenes) | |
| 675 | + | } | |
| 676 | + | for len(commands) > 0 { | |
| 677 | + | <-commands | |
| 678 | + | } | |
| 679 | + | // The window's far corner is outside both popups. | |
| 680 | + | input <- ports.PointerMotion{X: 90, Y: 70} | |
| 681 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 682 | + | var closed []ports.WindowID | |
| 683 | + | for len(closed) < 2 { | |
| 684 | + | if v, ok := command(t, commands).(ports.ClosePopup); ok { | |
| 685 | + | closed = append(closed, v.ID) | |
| 686 | + | } | |
| 687 | + | } | |
| 688 | + | if closed[0] != 3 || closed[1] != 2 { | |
| 689 | + | t.Fatalf("close order %v, want [3 2]", closed) | |
| 690 | + | } | |
| 691 | + | } | |
| @@ -641,3 +641,51 @@ func TestPointerConstraint(t *testing.T) { | |||||
| 641 | t.Fatalf("release %+v", got) | 641 | t.Fatalf("release %+v", got) | ||
| 642 | } | 642 | } | ||
| 643 | } | 643 | } | ||
| 644 | + | ||||
| 645 | + | // A click outside a grabbing menu chain closes it, the topmost popup first. | |||
| 646 | + | func TestPopupChainDismissOrder(t *testing.T) { | |||
| 647 | + | cfg := config.Defaults() | |||
| 648 | + | cfg.Border.Width = 0 | |||
| 649 | + | client := make(chan ports.ClientEvent, 8) | |||
| 650 | + | input := make(chan ports.InputEvent, 8) | |||
| 651 | + | output := make(chan ports.OutputEvent, 8) | |||
| 652 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 653 | + | scenes := make(chan []ports.Scene, 1) | |||
| 654 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 655 | + | if err != nil { | |||
| 656 | + | t.Fatal(err) | |||
| 657 | + | } | |||
| 658 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 659 | + | defer cancel() | |||
| 660 | + | go c.Run(ctx) | |||
| 661 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 662 | + | scene(t, scenes) | |||
| 663 | + | client <- ports.WindowMapped{ID: 1} | |||
| 664 | + | scene(t, scenes) | |||
| 665 | + | pos := ports.Positioner{Width: 10, Height: 10, AnchorRect: ports.Rect{W: 1, H: 1}, Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight} | |||
| 666 | + | for _, v := range []struct{ id, parent ports.WindowID }{{2, 1}, {3, 2}} { | |||
| 667 | + | client <- ports.PopupRequest{ID: v.id, Parent: v.parent, Positioner: pos, Grab: true} | |||
| 668 | + | for { | |||
| 669 | + | if cp, ok := command(t, commands).(ports.ConfigurePopup); ok && cp.ID == v.id { | |||
| 670 | + | break | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | client <- ports.PopupMapped{ID: v.id} | |||
| 674 | + | scene(t, scenes) | |||
| 675 | + | } | |||
| 676 | + | for len(commands) > 0 { | |||
| 677 | + | <-commands | |||
| 678 | + | } | |||
| 679 | + | // The window's far corner is outside both popups. | |||
| 680 | + | input <- ports.PointerMotion{X: 90, Y: 70} | |||
| 681 | + | input <- ports.PointerButton{Button: 0x110, Pressed: true} | |||
| 682 | + | var closed []ports.WindowID | |||
| 683 | + | for len(closed) < 2 { | |||
| 684 | + | if v, ok := command(t, commands).(ports.ClosePopup); ok { | |||
| 685 | + | closed = append(closed, v.ID) | |||
| 686 | + | } | |||
| 687 | + | } | |||
| 688 | + | if closed[0] != 3 || closed[1] != 2 { | |||
| 689 | + | t.Fatalf("close order %v, want [3 2]", closed) | |||
| 690 | + | } | |||
| 691 | + | } | |||
M internal/core/layout.go +9 -4
| @@ -195,7 +195,10 @@ 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 | - | return w.Floats[len(w.Floats)-1].ID, true | |
| 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 | + | } | |
| 199 | 202 | } | |
| 200 | 203 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| 201 | 204 | return 0, false | |
| @@ -593,6 +596,8 @@ func (w *Workspace) Layout() []Placement { | |||
| 593 | 596 | var result []Placement | |
| 594 | 597 | gap := w.gap() | |
| 595 | 598 | cols := w.columnRects() | |
| 599 | + | focusedID, _ := w.Focused() | |
| 600 | + | floatFocused := w.floatIndex(focusedID) >= 0 | |
| 596 | 601 | for i, c := range w.Columns { | |
| 597 | 602 | col := cols[i] | |
| 598 | 603 | n := len(c.Windows) | |
| @@ -629,14 +634,14 @@ func (w *Workspace) Layout() []Placement { | |||
| 629 | 634 | if hidden { | |
| 630 | 635 | r = Rect{} | |
| 631 | 636 | } | |
| 632 | - | focused := !w.floatFocus && i == w.Focus && j == c.Focus | |
| 637 | + | focused := !floatFocused && i == w.Focus && j == c.Focus | |
| 633 | 638 | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | |
| 634 | 639 | y += h + gap | |
| 635 | 640 | } | |
| 636 | 641 | } | |
| 637 | 642 | // Floating windows go last: they are drawn and hit on top. | |
| 638 | - | for i, f := range w.Floats { | |
| 639 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: w.floatFocus && i == len(w.Floats)-1} | |
| 643 | + | for _, f := range w.Floats { | |
| 644 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} | |
| 640 | 645 | if w.fullscreen == f.ID { | |
| 641 | 646 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 642 | 647 | } else if w.fullscreen != 0 { | |
| @@ -195,7 +195,10 @@ 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 | - | return w.Floats[len(w.Floats)-1].ID, true | 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 | + | } | |||
| 199 | } | 202 | } | ||
| 200 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | 203 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | ||
| 201 | return 0, false | 204 | return 0, false | ||
| @@ -593,6 +596,8 @@ func (w *Workspace) Layout() []Placement { | |||||
| 593 | var result []Placement | 596 | var result []Placement | ||
| 594 | gap := w.gap() | 597 | gap := w.gap() | ||
| 595 | cols := w.columnRects() | 598 | cols := w.columnRects() | ||
| 599 | + | focusedID, _ := w.Focused() | |||
| 600 | + | floatFocused := w.floatIndex(focusedID) >= 0 | |||
| 596 | for i, c := range w.Columns { | 601 | for i, c := range w.Columns { | ||
| 597 | col := cols[i] | 602 | col := cols[i] | ||
| 598 | n := len(c.Windows) | 603 | n := len(c.Windows) | ||
| @@ -629,14 +634,14 @@ func (w *Workspace) Layout() []Placement { | |||||
| 629 | if hidden { | 634 | if hidden { | ||
| 630 | r = Rect{} | 635 | r = Rect{} | ||
| 631 | } | 636 | } | ||
| 632 | - | focused := !w.floatFocus && i == w.Focus && j == c.Focus | 637 | + | focused := !floatFocused && i == w.Focus && j == c.Focus |
| 633 | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | 638 | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | ||
| 634 | y += h + gap | 639 | y += h + gap | ||
| 635 | } | 640 | } | ||
| 636 | } | 641 | } | ||
| 637 | // Floating windows go last: they are drawn and hit on top. | 642 | // Floating windows go last: they are drawn and hit on top. | ||
| 638 | - | for i, f := range w.Floats { | 643 | + | for _, f := range w.Floats { |
| 639 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: w.floatFocus && i == len(w.Floats)-1} | 644 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: floatFocused && f.ID == focusedID} |
| 640 | if w.fullscreen == f.ID { | 645 | if w.fullscreen == f.ID { | ||
| 641 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | 646 | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | ||
| 642 | } else if w.fullscreen != 0 { | 647 | } else if w.fullscreen != 0 { | ||
M internal/core/monitor.go +9 -0
| @@ -231,6 +231,15 @@ func (m *Monitor) AddWindow(id WindowID) { | |||
| 231 | 231 | m.normalize() | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | + | // AddFloating adds a floating window to the active workspace. | |
| 235 | + | func (m *Monitor) AddFloating(id WindowID, width, height int) { | |
| 236 | + | if w, _ := m.find(id); w != nil { | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | m.Current().AddFloating(id, width, height) | |
| 240 | + | m.normalize() | |
| 241 | + | } | |
| 242 | + | ||
| 234 | 243 | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | |
| 235 | 244 | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 236 | 245 | if w, _ := m.find(id); w != nil { | |
| @@ -231,6 +231,15 @@ func (m *Monitor) AddWindow(id WindowID) { | |||||
| 231 | m.normalize() | 231 | m.normalize() | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | + | // AddFloating adds a floating window to the active workspace. | |||
| 235 | + | func (m *Monitor) AddFloating(id WindowID, width, height int) { | |||
| 236 | + | if w, _ := m.find(id); w != nil { | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | m.Current().AddFloating(id, width, height) | |||
| 240 | + | m.normalize() | |||
| 241 | + | } | |||
| 242 | + | ||||
| 234 | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | 243 | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | ||
| 235 | func (m *Monitor) RemoveWindow(id WindowID) { | 244 | func (m *Monitor) RemoveWindow(id WindowID) { | ||
| 236 | if w, _ := m.find(id); w != nil { | 245 | if w, _ := m.find(id); w != nil { | ||
M internal/core/popup.go +40 -36
| @@ -153,7 +153,9 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 153 | 153 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |
| 154 | 154 | sc, pr, ok := c.windowRect(v.Parent) | |
| 155 | 155 | if !ok { | |
| 156 | - | delete(c.popups, v.ID) | |
| 156 | + | if c.popups[v.ID] != nil { | |
| 157 | + | return c.closePopup(ctx, v.ID) | |
| 158 | + | } | |
| 157 | 159 | return c.command(ctx, ports.ClosePopup{ID: v.ID}) | |
| 158 | 160 | } | |
| 159 | 161 | o := sc.mon.Output() | |
| @@ -169,37 +171,25 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |||
| 169 | 171 | return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token}) | |
| 170 | 172 | } | |
| 171 | 173 | ||
| 172 | - | // dropPopup forgets a popup and dismisses its children. | |
| 173 | - | func (c *Core) dropPopup(ctx context.Context, id WindowID) error { | |
| 174 | - | if c.popups[id] == nil { | |
| 175 | - | return nil | |
| 176 | - | } | |
| 177 | - | delete(c.popups, id) | |
| 178 | - | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id }) | |
| 179 | - | for _, child := range slices.Clone(c.popupOrder) { | |
| 180 | - | if p := c.popups[child]; p != nil && p.parent == id { | |
| 181 | - | if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil { | |
| 182 | - | return err | |
| 183 | - | } | |
| 184 | - | if err := c.dropPopup(ctx, child); err != nil { | |
| 185 | - | return err | |
| 186 | - | } | |
| 187 | - | } | |
| 174 | + | // closePopup dismisses a popup after its children, topmost first as | |
| 175 | + | // xdg-shell requires, and forgets them. | |
| 176 | + | func (c *Core) closePopup(ctx context.Context, id WindowID) error { | |
| 177 | + | if err := c.closePopupsOf(ctx, id); err != nil { | |
| 178 | + | return err | |
| 188 | 179 | } | |
| 189 | - | if c.pointer == id { | |
| 190 | - | c.pointer = 0 | |
| 180 | + | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | |
| 181 | + | return err | |
| 191 | 182 | } | |
| 183 | + | c.forgetPopup(id) | |
| 192 | 184 | return nil | |
| 193 | 185 | } | |
| 194 | 186 | ||
| 195 | - | // dropPopupsOf dismisses the popups hanging from a window. | |
| 196 | - | func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error { | |
| 197 | - | for _, child := range slices.Clone(c.popupOrder) { | |
| 187 | + | // closePopupsOf dismisses the popups hanging from a window or popup, | |
| 188 | + | // newest first. | |
| 189 | + | func (c *Core) closePopupsOf(ctx context.Context, id WindowID) error { | |
| 190 | + | for _, child := range slices.Backward(slices.Clone(c.popupOrder)) { | |
| 198 | 191 | if p := c.popups[child]; p != nil && p.parent == id { | |
| 199 | - | if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil { | |
| 200 | - | return err | |
| 201 | - | } | |
| 202 | - | if err := c.dropPopup(ctx, child); err != nil { | |
| 192 | + | if err := c.closePopup(ctx, child); err != nil { | |
| 203 | 193 | return err | |
| 204 | 194 | } | |
| 205 | 195 | } | |
| @@ -207,17 +197,34 @@ func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error { | |||
| 207 | 197 | return nil | |
| 208 | 198 | } | |
| 209 | 199 | ||
| 200 | + | // dropPopup forgets a popup the client unmapped and dismisses its children. | |
| 201 | + | func (c *Core) dropPopup(ctx context.Context, id WindowID) error { | |
| 202 | + | if c.popups[id] == nil { | |
| 203 | + | return nil | |
| 204 | + | } | |
| 205 | + | if err := c.closePopupsOf(ctx, id); err != nil { | |
| 206 | + | return err | |
| 207 | + | } | |
| 208 | + | c.forgetPopup(id) | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (c *Core) forgetPopup(id WindowID) { | |
| 213 | + | delete(c.popups, id) | |
| 214 | + | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id }) | |
| 215 | + | if c.pointer == id { | |
| 216 | + | c.pointer = 0 | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 210 | 220 | // closeHiddenPopups dismisses popups whose window left the screen. | |
| 211 | 221 | func (c *Core) closeHiddenPopups(ctx context.Context) error { | |
| 212 | - | for _, id := range slices.Clone(c.popupOrder) { | |
| 222 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |
| 213 | 223 | if c.popups[id] == nil { | |
| 214 | 224 | continue | |
| 215 | 225 | } | |
| 216 | 226 | if _, _, ok := c.windowRect(id); !ok { | |
| 217 | - | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | |
| 218 | - | return err | |
| 219 | - | } | |
| 220 | - | if err := c.dropPopup(ctx, id); err != nil { | |
| 227 | + | if err := c.closePopup(ctx, id); err != nil { | |
| 221 | 228 | return err | |
| 222 | 229 | } | |
| 223 | 230 | } | |
| @@ -228,7 +235,7 @@ func (c *Core) closeHiddenPopups(ctx context.Context) error { | |||
| 228 | 235 | // dismissGrabs closes the grabbing popups unless the click hit one of | |
| 229 | 236 | // them: a click outside a menu closes it. | |
| 230 | 237 | func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | |
| 231 | - | for _, id := range slices.Clone(c.popupOrder) { | |
| 238 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |
| 232 | 239 | p := c.popups[id] | |
| 233 | 240 | if p == nil || !p.grab { | |
| 234 | 241 | continue | |
| @@ -238,10 +245,7 @@ func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | |||
| 238 | 245 | if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) { | |
| 239 | 246 | continue | |
| 240 | 247 | } | |
| 241 | - | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | |
| 242 | - | return err | |
| 243 | - | } | |
| 244 | - | if err := c.dropPopup(ctx, id); err != nil { | |
| 248 | + | if err := c.closePopup(ctx, id); err != nil { | |
| 245 | 249 | return err | |
| 246 | 250 | } | |
| 247 | 251 | } | |
| @@ -153,7 +153,9 @@ func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||||
| 153 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | 153 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | ||
| 154 | sc, pr, ok := c.windowRect(v.Parent) | 154 | sc, pr, ok := c.windowRect(v.Parent) | ||
| 155 | if !ok { | 155 | if !ok { | ||
| 156 | - | delete(c.popups, v.ID) | 156 | + | if c.popups[v.ID] != nil { |
| 157 | + | return c.closePopup(ctx, v.ID) | |||
| 158 | + | } | |||
| 157 | return c.command(ctx, ports.ClosePopup{ID: v.ID}) | 159 | return c.command(ctx, ports.ClosePopup{ID: v.ID}) | ||
| 158 | } | 160 | } | ||
| 159 | o := sc.mon.Output() | 161 | o := sc.mon.Output() | ||
| @@ -169,37 +171,25 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |||||
| 169 | return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token}) | 171 | return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token}) | ||
| 170 | } | 172 | } | ||
| 171 | 173 | ||||
| 172 | - | // dropPopup forgets a popup and dismisses its children. | 174 | + | // closePopup dismisses a popup after its children, topmost first as |
| 173 | - | func (c *Core) dropPopup(ctx context.Context, id WindowID) error { | 175 | + | // xdg-shell requires, and forgets them. |
| 174 | - | if c.popups[id] == nil { | 176 | + | func (c *Core) closePopup(ctx context.Context, id WindowID) error { |
| 175 | - | return nil | 177 | + | if err := c.closePopupsOf(ctx, id); err != nil { |
| 176 | - | } | 178 | + | return err |
| 177 | - | delete(c.popups, id) | |||
| 178 | - | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id }) | |||
| 179 | - | for _, child := range slices.Clone(c.popupOrder) { | |||
| 180 | - | if p := c.popups[child]; p != nil && p.parent == id { | |||
| 181 | - | if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil { | |||
| 182 | - | return err | |||
| 183 | - | } | |||
| 184 | - | if err := c.dropPopup(ctx, child); err != nil { | |||
| 185 | - | return err | |||
| 186 | - | } | |||
| 187 | - | } | |||
| 188 | } | 179 | } | ||
| 189 | - | if c.pointer == id { | 180 | + | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { |
| 190 | - | c.pointer = 0 | 181 | + | return err |
| 191 | } | 182 | } | ||
| 183 | + | c.forgetPopup(id) | |||
| 192 | return nil | 184 | return nil | ||
| 193 | } | 185 | } | ||
| 194 | 186 | ||||
| 195 | - | // dropPopupsOf dismisses the popups hanging from a window. | 187 | + | // closePopupsOf dismisses the popups hanging from a window or popup, |
| 196 | - | func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error { | 188 | + | // newest first. |
| 197 | - | for _, child := range slices.Clone(c.popupOrder) { | 189 | + | func (c *Core) closePopupsOf(ctx context.Context, id WindowID) error { |
| 190 | + | for _, child := range slices.Backward(slices.Clone(c.popupOrder)) { | |||
| 198 | if p := c.popups[child]; p != nil && p.parent == id { | 191 | if p := c.popups[child]; p != nil && p.parent == id { | ||
| 199 | - | if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil { | 192 | + | if err := c.closePopup(ctx, child); err != nil { |
| 200 | - | return err | |||
| 201 | - | } | |||
| 202 | - | if err := c.dropPopup(ctx, child); err != nil { | |||
| 203 | return err | 193 | return err | ||
| 204 | } | 194 | } | ||
| 205 | } | 195 | } | ||
| @@ -207,17 +197,34 @@ func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error { | |||||
| 207 | return nil | 197 | return nil | ||
| 208 | } | 198 | } | ||
| 209 | 199 | ||||
| 200 | + | // dropPopup forgets a popup the client unmapped and dismisses its children. | |||
| 201 | + | func (c *Core) dropPopup(ctx context.Context, id WindowID) error { | |||
| 202 | + | if c.popups[id] == nil { | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | if err := c.closePopupsOf(ctx, id); err != nil { | |||
| 206 | + | return err | |||
| 207 | + | } | |||
| 208 | + | c.forgetPopup(id) | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (c *Core) forgetPopup(id WindowID) { | |||
| 213 | + | delete(c.popups, id) | |||
| 214 | + | c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id }) | |||
| 215 | + | if c.pointer == id { | |||
| 216 | + | c.pointer = 0 | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 210 | // closeHiddenPopups dismisses popups whose window left the screen. | 220 | // closeHiddenPopups dismisses popups whose window left the screen. | ||
| 211 | func (c *Core) closeHiddenPopups(ctx context.Context) error { | 221 | func (c *Core) closeHiddenPopups(ctx context.Context) error { | ||
| 212 | - | for _, id := range slices.Clone(c.popupOrder) { | 222 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { |
| 213 | if c.popups[id] == nil { | 223 | if c.popups[id] == nil { | ||
| 214 | continue | 224 | continue | ||
| 215 | } | 225 | } | ||
| 216 | if _, _, ok := c.windowRect(id); !ok { | 226 | if _, _, ok := c.windowRect(id); !ok { | ||
| 217 | - | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | 227 | + | if err := c.closePopup(ctx, id); err != nil { |
| 218 | - | return err | |||
| 219 | - | } | |||
| 220 | - | if err := c.dropPopup(ctx, id); err != nil { | |||
| 221 | return err | 228 | return err | ||
| 222 | } | 229 | } | ||
| 223 | } | 230 | } | ||
| @@ -228,7 +235,7 @@ func (c *Core) closeHiddenPopups(ctx context.Context) error { | |||||
| 228 | // dismissGrabs closes the grabbing popups unless the click hit one of | 235 | // dismissGrabs closes the grabbing popups unless the click hit one of | ||
| 229 | // them: a click outside a menu closes it. | 236 | // them: a click outside a menu closes it. | ||
| 230 | func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | 237 | func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | ||
| 231 | - | for _, id := range slices.Clone(c.popupOrder) { | 238 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { |
| 232 | p := c.popups[id] | 239 | p := c.popups[id] | ||
| 233 | if p == nil || !p.grab { | 240 | if p == nil || !p.grab { | ||
| 234 | continue | 241 | continue | ||
| @@ -238,10 +245,7 @@ func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | |||||
| 238 | if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) { | 245 | if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) { | ||
| 239 | continue | 246 | continue | ||
| 240 | } | 247 | } | ||
| 241 | - | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | 248 | + | if err := c.closePopup(ctx, id); err != nil { |
| 242 | - | return err | |||
| 243 | - | } | |||
| 244 | - | if err := c.dropPopup(ctx, id); err != nil { | |||
| 245 | return err | 249 | return err | ||
| 246 | } | 250 | } | ||
| 247 | } | 251 | } | ||