merge: popups (xdg_popup support and floating dialogs)
16 files · +1258 -86
M internal/adapters/vulkan/renderer.go +6 -0
| @@ -506,6 +506,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 506 | 506 | continue | |
| 507 | 507 | } | |
| 508 | 508 | x, y := w.Rect.X, w.Rect.Y | |
| 509 | + | if w.Popup { | |
| 510 | + | // Menus draw only what the client drew, shadows clipped. | |
| 511 | + | content := contents[w.ID] | |
| 512 | + | place(&content, x, y, w.Rect.W, w.Rect.H) | |
| 513 | + | continue | |
| 514 | + | } | |
| 509 | 515 | // Content sits inside the border; core sized the client to match. | |
| 510 | 516 | b := 0 | |
| 511 | 517 | if !w.Fullscreen && !w.Borderless { | |
| @@ -506,6 +506,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 506 | continue | 506 | continue | ||
| 507 | } | 507 | } | ||
| 508 | x, y := w.Rect.X, w.Rect.Y | 508 | x, y := w.Rect.X, w.Rect.Y | ||
| 509 | + | if w.Popup { | |||
| 510 | + | // Menus draw only what the client drew, shadows clipped. | |||
| 511 | + | content := contents[w.ID] | |||
| 512 | + | place(&content, x, y, w.Rect.W, w.Rect.H) | |||
| 513 | + | continue | |||
| 514 | + | } | |||
| 509 | // Content sits inside the border; core sized the client to match. | 515 | // Content sits inside the border; core sized the client to match. | ||
| 510 | b := 0 | 516 | b := 0 | ||
| 511 | if !w.Fullscreen && !w.Borderless { | 517 | if !w.Fullscreen && !w.Borderless { | ||
A internal/adapters/wayland/popup.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 6 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // popup is the xdg_popup role of a window. Core places it (PopupRequest → | |
| 10 | + | // ConfigurePopup); it maps on the first buffer after its configure is | |
| 11 | + | // acked, and is drawn over its parent like a window without border. | |
| 12 | + | type popup struct { | |
| 13 | + | w *window | |
| 14 | + | resource *xdgshell.Popup | |
| 15 | + | parent *window | |
| 16 | + | pos ports.Positioner | |
| 17 | + | grab bool | |
| 18 | + | // sent is set once core got the PopupRequest; done once dismissed. | |
| 19 | + | sent, done bool | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentXDG *xdgshell.Surface, pos *xdgshell.Positioner) { | |
| 23 | + | p, ok := s.positionerOf(x.wm, pos) | |
| 24 | + | if !ok { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | var parent *window | |
| 28 | + | if parentXDG != nil { | |
| 29 | + | for _, w := range s.windows { | |
| 30 | + | if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource { | |
| 31 | + | parent = w | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | w := &window{id: s.nextWindow, xdg: x} | |
| 36 | + | pp := &popup{w: w, parent: parent, pos: p} | |
| 37 | + | res, err := xdgshell.NewPopup(r.Client(), r.Version(), id, pp) | |
| 38 | + | if err != nil { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | pp.resource, w.popup = res, pp | |
| 42 | + | s.nextWindow++ | |
| 43 | + | s.windows[w.id] = w | |
| 44 | + | x.window = w | |
| 45 | + | // Without a parent (xdg-shell lets clients set it through another | |
| 46 | + | // protocol, such as layer-shell) the popup has nowhere to go. | |
| 47 | + | if parent == nil { | |
| 48 | + | pp.dismiss() | |
| 49 | + | } | |
| 50 | + | x.surface.role = func(buffer bool) { | |
| 51 | + | if x.surface.destroyed { | |
| 52 | + | w.unmap() | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | switch { | |
| 56 | + | case !buffer && !w.mapped && !x.configured && !pp.done: | |
| 57 | + | if !pp.sent { | |
| 58 | + | pp.sent = true | |
| 59 | + | s.emit(ports.PopupRequest{ID: w.id, Parent: parent.id, Positioner: pp.pos, Grab: pp.grab}) | |
| 60 | + | } | |
| 61 | + | case buffer && !w.mapped && x.acked && !pp.done: | |
| 62 | + | w.mapped = true | |
| 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") | |
| 65 | + | case !buffer && w.mapped: | |
| 66 | + | // Unmapped, it may map again after a new initial commit. | |
| 67 | + | w.unmap() | |
| 68 | + | pp.sent, pp.grab = false, false | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | res.OnDestroy = func() { | |
| 72 | + | // Core forgets the popup, placed or mapped. | |
| 73 | + | if !w.mapped && pp.sent && !pp.done { | |
| 74 | + | s.emit(ports.WindowUnmapped{ID: w.id}) | |
| 75 | + | } | |
| 76 | + | w.unmap() | |
| 77 | + | pp.done = true | |
| 78 | + | x.surface.current, x.surface.pending = nil, nil | |
| 79 | + | x.surface.attached = false | |
| 80 | + | delete(s.windows, w.id) | |
| 81 | + | x.window = nil | |
| 82 | + | x.surface.role = nil | |
| 83 | + | x.configured, x.acked, x.serials = false, false, nil | |
| 84 | + | x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{} | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (*popup) Destroy(*xdgshell.Popup) {} | |
| 89 | + | ||
| 90 | + | // Grab is only valid before the first commit; core then gives the popup the | |
| 91 | + | // keyboard and closes it on a click outside its popup chain. | |
| 92 | + | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |
| 93 | + | if p.sent || p.w.mapped { | |
| 94 | + | r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed") | |
| 95 | + | return | |
| 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 == r.Client() | |
| 101 | + | if !fresh || p.parent == nil || !s.topGrab(p.parent) { | |
| 102 | + | p.dismiss() | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | p.grab = true | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token uint32) { | |
| 109 | + | s := p.w.xdg.server | |
| 110 | + | next, ok := s.positionerOf(p.w.xdg.wm, pos) | |
| 111 | + | if !ok || p.done { | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | p.pos = next | |
| 115 | + | if p.sent { | |
| 116 | + | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token}) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // configure sends core's placement: xdg_popup.configure then | |
| 121 | + | // xdg_surface.configure. | |
| 122 | + | func (p *popup) configure(c ports.ConfigurePopup) { | |
| 123 | + | if p.done || !p.resource.Resource.Alive() || !p.w.xdg.resource.Resource.Alive() { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | if c.Reposition && p.resource.Version() >= 3 { | |
| 127 | + | p.resource.SendRepositioned(c.Token) | |
| 128 | + | } | |
| 129 | + | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | |
| 130 | + | s := p.w.xdg.server | |
| 131 | + | s.serial++ | |
| 132 | + | p.w.xdg.resource.SendConfigure(s.serial) | |
| 133 | + | p.w.xdg.serials = append(p.w.xdg.serials, s.serial) | |
| 134 | + | p.w.xdg.configured = true | |
| 135 | + | if surf := p.w.xdg.surface; surf != nil { | |
| 136 | + | surf.sendTreeScale() | |
| 137 | + | } | |
| 138 | + | } | |
| 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 | + | ||
| 156 | + | // dismiss sends popup_done once; the client then destroys the popup. | |
| 157 | + | func (p *popup) dismiss() { | |
| 158 | + | if p.done { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | p.done = true | |
| 162 | + | if p.resource.Resource.Alive() { | |
| 163 | + | p.resource.SendPopupDone() | |
| 164 | + | } | |
| 165 | + | p.w.unmap() | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 5 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 6 | + | "github.com/bnema/purego-libwayland/protocol/xdgshell" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // popup is the xdg_popup role of a window. Core places it (PopupRequest → | |||
| 10 | + | // ConfigurePopup); it maps on the first buffer after its configure is | |||
| 11 | + | // acked, and is drawn over its parent like a window without border. | |||
| 12 | + | type popup struct { | |||
| 13 | + | w *window | |||
| 14 | + | resource *xdgshell.Popup | |||
| 15 | + | parent *window | |||
| 16 | + | pos ports.Positioner | |||
| 17 | + | grab bool | |||
| 18 | + | // sent is set once core got the PopupRequest; done once dismissed. | |||
| 19 | + | sent, done bool | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentXDG *xdgshell.Surface, pos *xdgshell.Positioner) { | |||
| 23 | + | p, ok := s.positionerOf(x.wm, pos) | |||
| 24 | + | if !ok { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | var parent *window | |||
| 28 | + | if parentXDG != nil { | |||
| 29 | + | for _, w := range s.windows { | |||
| 30 | + | if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource { | |||
| 31 | + | parent = w | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | w := &window{id: s.nextWindow, xdg: x} | |||
| 36 | + | pp := &popup{w: w, parent: parent, pos: p} | |||
| 37 | + | res, err := xdgshell.NewPopup(r.Client(), r.Version(), id, pp) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | pp.resource, w.popup = res, pp | |||
| 42 | + | s.nextWindow++ | |||
| 43 | + | s.windows[w.id] = w | |||
| 44 | + | x.window = w | |||
| 45 | + | // Without a parent (xdg-shell lets clients set it through another | |||
| 46 | + | // protocol, such as layer-shell) the popup has nowhere to go. | |||
| 47 | + | if parent == nil { | |||
| 48 | + | pp.dismiss() | |||
| 49 | + | } | |||
| 50 | + | x.surface.role = func(buffer bool) { | |||
| 51 | + | if x.surface.destroyed { | |||
| 52 | + | w.unmap() | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | switch { | |||
| 56 | + | case !buffer && !w.mapped && !x.configured && !pp.done: | |||
| 57 | + | if !pp.sent { | |||
| 58 | + | pp.sent = true | |||
| 59 | + | s.emit(ports.PopupRequest{ID: w.id, Parent: parent.id, Positioner: pp.pos, Grab: pp.grab}) | |||
| 60 | + | } | |||
| 61 | + | case buffer && !w.mapped && x.acked && !pp.done: | |||
| 62 | + | w.mapped = true | |||
| 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") | |||
| 65 | + | case !buffer && w.mapped: | |||
| 66 | + | // Unmapped, it may map again after a new initial commit. | |||
| 67 | + | w.unmap() | |||
| 68 | + | pp.sent, pp.grab = false, false | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | res.OnDestroy = func() { | |||
| 72 | + | // Core forgets the popup, placed or mapped. | |||
| 73 | + | if !w.mapped && pp.sent && !pp.done { | |||
| 74 | + | s.emit(ports.WindowUnmapped{ID: w.id}) | |||
| 75 | + | } | |||
| 76 | + | w.unmap() | |||
| 77 | + | pp.done = true | |||
| 78 | + | x.surface.current, x.surface.pending = nil, nil | |||
| 79 | + | x.surface.attached = false | |||
| 80 | + | delete(s.windows, w.id) | |||
| 81 | + | x.window = nil | |||
| 82 | + | x.surface.role = nil | |||
| 83 | + | x.configured, x.acked, x.serials = false, false, nil | |||
| 84 | + | x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{} | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (*popup) Destroy(*xdgshell.Popup) {} | |||
| 89 | + | ||||
| 90 | + | // Grab is only valid before the first commit; core then gives the popup the | |||
| 91 | + | // keyboard and closes it on a click outside its popup chain. | |||
| 92 | + | func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, serial uint32) { | |||
| 93 | + | if p.sent || p.w.mapped { | |||
| 94 | + | r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed") | |||
| 95 | + | return | |||
| 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 == r.Client() | |||
| 101 | + | if !fresh || p.parent == nil || !s.topGrab(p.parent) { | |||
| 102 | + | p.dismiss() | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | p.grab = true | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token uint32) { | |||
| 109 | + | s := p.w.xdg.server | |||
| 110 | + | next, ok := s.positionerOf(p.w.xdg.wm, pos) | |||
| 111 | + | if !ok || p.done { | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | p.pos = next | |||
| 115 | + | if p.sent { | |||
| 116 | + | s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token}) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // configure sends core's placement: xdg_popup.configure then | |||
| 121 | + | // xdg_surface.configure. | |||
| 122 | + | func (p *popup) configure(c ports.ConfigurePopup) { | |||
| 123 | + | if p.done || !p.resource.Resource.Alive() || !p.w.xdg.resource.Resource.Alive() { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | if c.Reposition && p.resource.Version() >= 3 { | |||
| 127 | + | p.resource.SendRepositioned(c.Token) | |||
| 128 | + | } | |||
| 129 | + | p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H)) | |||
| 130 | + | s := p.w.xdg.server | |||
| 131 | + | s.serial++ | |||
| 132 | + | p.w.xdg.resource.SendConfigure(s.serial) | |||
| 133 | + | p.w.xdg.serials = append(p.w.xdg.serials, s.serial) | |||
| 134 | + | p.w.xdg.configured = true | |||
| 135 | + | if surf := p.w.xdg.surface; surf != nil { | |||
| 136 | + | surf.sendTreeScale() | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 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 | + | ||||
| 156 | + | // dismiss sends popup_done once; the client then destroys the popup. | |||
| 157 | + | func (p *popup) dismiss() { | |||
| 158 | + | if p.done { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | p.done = true | |||
| 162 | + | if p.resource.Resource.Alive() { | |||
| 163 | + | p.resource.SendPopupDone() | |||
| 164 | + | } | |||
| 165 | + | p.w.unmap() | |||
| 166 | + | } | |||
M internal/adapters/wayland/protocol_test.go +142 -0
| @@ -157,6 +157,8 @@ func TestPopupBlocksSecondRole(t *testing.T) { | |||
| 157 | 157 | registerProtocol(t, c, surf) | |
| 158 | 158 | positioner := c.AllocateID() | |
| 159 | 159 | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |
| 160 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(10), int32(10)) | |
| 161 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |
| 160 | 162 | if err := c.Roundtrip(); err != nil { | |
| 161 | 163 | t.Fatal(err) | |
| 162 | 164 | } | |
| @@ -647,3 +649,143 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE | |||
| 647 | 649 | return mapped(t, events, 2*time.Second), surf, xdg | |
| 648 | 650 | } | |
| 649 | 651 | } | |
| 652 | + | ||
| 653 | + | type popupDoneProxy struct { | |
| 654 | + | wlturbo.BaseProxy | |
| 655 | + | done chan struct{} | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func (p *popupDoneProxy) Dispatch(e *wlturbo.Event) { | |
| 659 | + | if e.Opcode == uint16(xdgshell.PopupEventPopupDone) { | |
| 660 | + | p.done <- struct{}{} | |
| 661 | + | } | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | // xdgWindow maps a 1×1 xdg_surface; role sets its role before the first commit. | |
| 665 | + | func xdgWindow(t *testing.T, c *wlturbo.Display, comp, wm, buffer uint32, role func(xdg uint32)) (surf, xdg uint32, serials chan uint32) { | |
| 666 | + | t.Helper() | |
| 667 | + | surf, xdg = c.AllocateID(), c.AllocateID() | |
| 668 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 669 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |
| 670 | + | registerProtocol(t, c, surf) | |
| 671 | + | serials = make(chan uint32, 4) | |
| 672 | + | proxy := &configureProxy{serial: serials} | |
| 673 | + | proxy.SetID(xdg) | |
| 674 | + | c.Context().Register(proxy) | |
| 675 | + | role(xdg) | |
| 676 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 677 | + | if err := c.Roundtrip(); err != nil { | |
| 678 | + | t.Fatal(err) | |
| 679 | + | } | |
| 680 | + | return surf, xdg, serials | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func ackAndAttach(t *testing.T, c *wlturbo.Display, surf, xdg, buffer uint32, serials chan uint32) { | |
| 684 | + | t.Helper() | |
| 685 | + | deadline := time.Now().Add(2 * time.Second) | |
| 686 | + | for len(serials) == 0 && time.Now().Before(deadline) { | |
| 687 | + | if err := c.Roundtrip(); err != nil { | |
| 688 | + | t.Fatal(err) | |
| 689 | + | } | |
| 690 | + | time.Sleep(10 * time.Millisecond) | |
| 691 | + | } | |
| 692 | + | select { | |
| 693 | + | case serial := <-serials: | |
| 694 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial) | |
| 695 | + | default: | |
| 696 | + | t.Fatal("configure timeout") | |
| 697 | + | } | |
| 698 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |
| 699 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 700 | + | if err := c.Roundtrip(); err != nil { | |
| 701 | + | t.Fatal(err) | |
| 702 | + | } | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func TestPopupAndFloatingLifecycle(t *testing.T) { | |
| 706 | + | s, events, commands, dir := lifecycleServer(t) | |
| 707 | + | c := protocolClient(t, s, dir) | |
| 708 | + | comp := bindProtocol(t, c, "wl_compositor") | |
| 709 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 710 | + | shm := bindProtocol(t, c, "wl_shm") | |
| 711 | + | registerProtocol(t, c, shm) | |
| 712 | + | fd, err := unix.MemfdCreate("popup-buffer", 0) | |
| 713 | + | if err != nil { | |
| 714 | + | t.Fatal(err) | |
| 715 | + | } | |
| 716 | + | defer unix.Close(fd) | |
| 717 | + | if err := unix.Ftruncate(fd, 4); err != nil { | |
| 718 | + | t.Fatal(err) | |
| 719 | + | } | |
| 720 | + | pool, buffer := c.AllocateID(), c.AllocateID() | |
| 721 | + | registerProtocol(t, c, pool) | |
| 722 | + | registerProtocol(t, c, buffer) | |
| 723 | + | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | |
| 724 | + | t.Fatal(err) | |
| 725 | + | } | |
| 726 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | |
| 727 | + | ||
| 728 | + | // A fixed-size toplevel (min == max) floats, like a splash screen. | |
| 729 | + | surf, xdg, serials := xdgWindow(t, c, comp, wm, buffer, func(xdg uint32) { | |
| 730 | + | top := c.AllocateID() | |
| 731 | + | registerProtocol(t, c, top) | |
| 732 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | |
| 733 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetMinSize, int32(1), int32(1)) | |
| 734 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetMaxSize, int32(1), int32(1)) | |
| 735 | + | }) | |
| 736 | + | ackAndAttach(t, c, surf, xdg, buffer, serials) | |
| 737 | + | top := mapped(t, events, 2*time.Second) | |
| 738 | + | if !top.Floating { | |
| 739 | + | t.Fatalf("splash not floating: %+v", top) | |
| 740 | + | } | |
| 741 | + | if ev, ok := waitEvent(t, events, 2*time.Second).(ports.WindowResized); !ok || ev.Width != 1 || ev.Height != 1 { | |
| 742 | + | t.Fatalf("splash size %+v", ev) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | positioner := c.AllocateID() | |
| 746 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |
| 747 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(1), int32(1)) | |
| 748 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |
| 749 | + | done := make(chan struct{}, 1) | |
| 750 | + | psurf, pxdg, pserials := xdgWindow(t, c, comp, wm, buffer, func(pxdg uint32) { | |
| 751 | + | popup := c.AllocateID() | |
| 752 | + | proxy := &popupDoneProxy{done: done} | |
| 753 | + | proxy.SetID(popup) | |
| 754 | + | c.Context().Register(proxy) | |
| 755 | + | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner) | |
| 756 | + | }) | |
| 757 | + | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | |
| 758 | + | if !ok || req.Parent != top.ID || req.Positioner.Width != 1 { | |
| 759 | + | t.Fatalf("popup request %+v", req) | |
| 760 | + | } | |
| 761 | + | commands <- ports.ConfigurePopup{ID: req.ID, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 1}} | |
| 762 | + | ackAndAttach(t, c, psurf, pxdg, buffer, pserials) | |
| 763 | + | if ev, ok := waitEvent(t, events, 2*time.Second).(ports.PopupMapped); !ok || ev.ID != req.ID { | |
| 764 | + | t.Fatalf("popup mapped %+v", ev) | |
| 765 | + | } | |
| 766 | + | commands <- ports.ClosePopup{ID: req.ID} | |
| 767 | + | for deadline := time.Now().Add(2 * time.Second); len(done) == 0; time.Sleep(10 * time.Millisecond) { | |
| 768 | + | if time.Now().After(deadline) { | |
| 769 | + | t.Fatal("popup_done timeout") | |
| 770 | + | } | |
| 771 | + | if err := c.Roundtrip(); err != nil { | |
| 772 | + | t.Fatal(err) | |
| 773 | + | } | |
| 774 | + | } | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | func TestPopupIncompletePositioner(t *testing.T) { | |
| 778 | + | s, _, _, dir := lifecycleServer(t) | |
| 779 | + | c := protocolClient(t, s, dir) | |
| 780 | + | comp := bindProtocol(t, c, "wl_compositor") | |
| 781 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |
| 782 | + | surf, xdg := c.AllocateID(), c.AllocateID() | |
| 783 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 784 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |
| 785 | + | registerProtocol(t, c, xdg) | |
| 786 | + | positioner := c.AllocateID() | |
| 787 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |
| 788 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(10), int32(10)) | |
| 789 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetPopup, c.AllocateID(), uint32(0), positioner) | |
| 790 | + | expectProtocolError(t, c, wm, uint32(xdgshell.WmBaseErrorInvalidPositioner)) | |
| 791 | + | } | |
| @@ -157,6 +157,8 @@ func TestPopupBlocksSecondRole(t *testing.T) { | |||||
| 157 | registerProtocol(t, c, surf) | 157 | registerProtocol(t, c, surf) | ||
| 158 | positioner := c.AllocateID() | 158 | positioner := c.AllocateID() | ||
| 159 | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | 159 | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | ||
| 160 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(10), int32(10)) | |||
| 161 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |||
| 160 | if err := c.Roundtrip(); err != nil { | 162 | if err := c.Roundtrip(); err != nil { | ||
| 161 | t.Fatal(err) | 163 | t.Fatal(err) | ||
| 162 | } | 164 | } | ||
| @@ -647,3 +649,143 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE | |||||
| 647 | return mapped(t, events, 2*time.Second), surf, xdg | 649 | return mapped(t, events, 2*time.Second), surf, xdg | ||
| 648 | } | 650 | } | ||
| 649 | } | 651 | } | ||
| 652 | + | ||||
| 653 | + | type popupDoneProxy struct { | |||
| 654 | + | wlturbo.BaseProxy | |||
| 655 | + | done chan struct{} | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | func (p *popupDoneProxy) Dispatch(e *wlturbo.Event) { | |||
| 659 | + | if e.Opcode == uint16(xdgshell.PopupEventPopupDone) { | |||
| 660 | + | p.done <- struct{}{} | |||
| 661 | + | } | |||
| 662 | + | } | |||
| 663 | + | ||||
| 664 | + | // xdgWindow maps a 1×1 xdg_surface; role sets its role before the first commit. | |||
| 665 | + | func xdgWindow(t *testing.T, c *wlturbo.Display, comp, wm, buffer uint32, role func(xdg uint32)) (surf, xdg uint32, serials chan uint32) { | |||
| 666 | + | t.Helper() | |||
| 667 | + | surf, xdg = c.AllocateID(), c.AllocateID() | |||
| 668 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |||
| 669 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |||
| 670 | + | registerProtocol(t, c, surf) | |||
| 671 | + | serials = make(chan uint32, 4) | |||
| 672 | + | proxy := &configureProxy{serial: serials} | |||
| 673 | + | proxy.SetID(xdg) | |||
| 674 | + | c.Context().Register(proxy) | |||
| 675 | + | role(xdg) | |||
| 676 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 677 | + | if err := c.Roundtrip(); err != nil { | |||
| 678 | + | t.Fatal(err) | |||
| 679 | + | } | |||
| 680 | + | return surf, xdg, serials | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | func ackAndAttach(t *testing.T, c *wlturbo.Display, surf, xdg, buffer uint32, serials chan uint32) { | |||
| 684 | + | t.Helper() | |||
| 685 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 686 | + | for len(serials) == 0 && time.Now().Before(deadline) { | |||
| 687 | + | if err := c.Roundtrip(); err != nil { | |||
| 688 | + | t.Fatal(err) | |||
| 689 | + | } | |||
| 690 | + | time.Sleep(10 * time.Millisecond) | |||
| 691 | + | } | |||
| 692 | + | select { | |||
| 693 | + | case serial := <-serials: | |||
| 694 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestAckConfigure, serial) | |||
| 695 | + | default: | |||
| 696 | + | t.Fatal("configure timeout") | |||
| 697 | + | } | |||
| 698 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0)) | |||
| 699 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |||
| 700 | + | if err := c.Roundtrip(); err != nil { | |||
| 701 | + | t.Fatal(err) | |||
| 702 | + | } | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | func TestPopupAndFloatingLifecycle(t *testing.T) { | |||
| 706 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 707 | + | c := protocolClient(t, s, dir) | |||
| 708 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 709 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |||
| 710 | + | shm := bindProtocol(t, c, "wl_shm") | |||
| 711 | + | registerProtocol(t, c, shm) | |||
| 712 | + | fd, err := unix.MemfdCreate("popup-buffer", 0) | |||
| 713 | + | if err != nil { | |||
| 714 | + | t.Fatal(err) | |||
| 715 | + | } | |||
| 716 | + | defer unix.Close(fd) | |||
| 717 | + | if err := unix.Ftruncate(fd, 4); err != nil { | |||
| 718 | + | t.Fatal(err) | |||
| 719 | + | } | |||
| 720 | + | pool, buffer := c.AllocateID(), c.AllocateID() | |||
| 721 | + | registerProtocol(t, c, pool) | |||
| 722 | + | registerProtocol(t, c, buffer) | |||
| 723 | + | if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil { | |||
| 724 | + | t.Fatal(err) | |||
| 725 | + | } | |||
| 726 | + | requestProtocol(t, c, pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0)) | |||
| 727 | + | ||||
| 728 | + | // A fixed-size toplevel (min == max) floats, like a splash screen. | |||
| 729 | + | surf, xdg, serials := xdgWindow(t, c, comp, wm, buffer, func(xdg uint32) { | |||
| 730 | + | top := c.AllocateID() | |||
| 731 | + | registerProtocol(t, c, top) | |||
| 732 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetToplevel, top) | |||
| 733 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetMinSize, int32(1), int32(1)) | |||
| 734 | + | requestProtocol(t, c, top, xdgshell.ToplevelRequestSetMaxSize, int32(1), int32(1)) | |||
| 735 | + | }) | |||
| 736 | + | ackAndAttach(t, c, surf, xdg, buffer, serials) | |||
| 737 | + | top := mapped(t, events, 2*time.Second) | |||
| 738 | + | if !top.Floating { | |||
| 739 | + | t.Fatalf("splash not floating: %+v", top) | |||
| 740 | + | } | |||
| 741 | + | if ev, ok := waitEvent(t, events, 2*time.Second).(ports.WindowResized); !ok || ev.Width != 1 || ev.Height != 1 { | |||
| 742 | + | t.Fatalf("splash size %+v", ev) | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | positioner := c.AllocateID() | |||
| 746 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |||
| 747 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(1), int32(1)) | |||
| 748 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetAnchorRect, int32(0), int32(0), int32(1), int32(1)) | |||
| 749 | + | done := make(chan struct{}, 1) | |||
| 750 | + | psurf, pxdg, pserials := xdgWindow(t, c, comp, wm, buffer, func(pxdg uint32) { | |||
| 751 | + | popup := c.AllocateID() | |||
| 752 | + | proxy := &popupDoneProxy{done: done} | |||
| 753 | + | proxy.SetID(popup) | |||
| 754 | + | c.Context().Register(proxy) | |||
| 755 | + | requestProtocol(t, c, pxdg, xdgshell.SurfaceRequestGetPopup, popup, xdg, positioner) | |||
| 756 | + | }) | |||
| 757 | + | req, ok := waitEvent(t, events, 2*time.Second).(ports.PopupRequest) | |||
| 758 | + | if !ok || req.Parent != top.ID || req.Positioner.Width != 1 { | |||
| 759 | + | t.Fatalf("popup request %+v", req) | |||
| 760 | + | } | |||
| 761 | + | commands <- ports.ConfigurePopup{ID: req.ID, Rect: ports.Rect{X: 2, Y: 3, W: 1, H: 1}} | |||
| 762 | + | ackAndAttach(t, c, psurf, pxdg, buffer, pserials) | |||
| 763 | + | if ev, ok := waitEvent(t, events, 2*time.Second).(ports.PopupMapped); !ok || ev.ID != req.ID { | |||
| 764 | + | t.Fatalf("popup mapped %+v", ev) | |||
| 765 | + | } | |||
| 766 | + | commands <- ports.ClosePopup{ID: req.ID} | |||
| 767 | + | for deadline := time.Now().Add(2 * time.Second); len(done) == 0; time.Sleep(10 * time.Millisecond) { | |||
| 768 | + | if time.Now().After(deadline) { | |||
| 769 | + | t.Fatal("popup_done timeout") | |||
| 770 | + | } | |||
| 771 | + | if err := c.Roundtrip(); err != nil { | |||
| 772 | + | t.Fatal(err) | |||
| 773 | + | } | |||
| 774 | + | } | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | func TestPopupIncompletePositioner(t *testing.T) { | |||
| 778 | + | s, _, _, dir := lifecycleServer(t) | |||
| 779 | + | c := protocolClient(t, s, dir) | |||
| 780 | + | comp := bindProtocol(t, c, "wl_compositor") | |||
| 781 | + | wm := bindProtocol(t, c, "xdg_wm_base") | |||
| 782 | + | surf, xdg := c.AllocateID(), c.AllocateID() | |||
| 783 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |||
| 784 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf) | |||
| 785 | + | registerProtocol(t, c, xdg) | |||
| 786 | + | positioner := c.AllocateID() | |||
| 787 | + | requestProtocol(t, c, wm, xdgshell.WmBaseRequestCreatePositioner, positioner) | |||
| 788 | + | requestProtocol(t, c, positioner, xdgshell.PositionerRequestSetSize, int32(10), int32(10)) | |||
| 789 | + | requestProtocol(t, c, xdg, xdgshell.SurfaceRequestGetPopup, c.AllocateID(), uint32(0), positioner) | |||
| 790 | + | expectProtocolError(t, c, wm, uint32(xdgshell.WmBaseErrorInvalidPositioner)) | |||
| 791 | + | } | |||
M internal/adapters/wayland/scale.go +4 -0
| @@ -18,6 +18,10 @@ import ( | |||
| 18 | 18 | func (s *Server) outputOfSurface(surf *surface) *output { | |
| 19 | 19 | // Subsurfaces are on their root's output. | |
| 20 | 20 | surf = surf.root() | |
| 21 | + | // Popups are on their parent's output. | |
| 22 | + | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | |
| 23 | + | surf = surf.xdg.window.popup.parent.xdg.surface.root() | |
| 24 | + | } | |
| 21 | 25 | name := "" | |
| 22 | 26 | switch { | |
| 23 | 27 | case surf.xdg != nil && surf.xdg.window != nil: | |
| @@ -18,6 +18,10 @@ import ( | |||||
| 18 | func (s *Server) outputOfSurface(surf *surface) *output { | 18 | func (s *Server) outputOfSurface(surf *surface) *output { | ||
| 19 | // Subsurfaces are on their root's output. | 19 | // Subsurfaces are on their root's output. | ||
| 20 | surf = surf.root() | 20 | surf = surf.root() | ||
| 21 | + | // Popups are on their parent's output. | |||
| 22 | + | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | |||
| 23 | + | surf = surf.xdg.window.popup.parent.xdg.surface.root() | |||
| 24 | + | } | |||
| 21 | name := "" | 25 | name := "" | ||
| 22 | switch { | 26 | switch { | ||
| 23 | case surf.xdg != nil && surf.xdg.window != nil: | 27 | case surf.xdg != nil && surf.xdg.window != nil: | ||
M internal/adapters/wayland/server.go +41 -22
| @@ -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 | |
| @@ -98,6 +102,7 @@ type Server struct { | |||
| 98 | 102 | relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1 | |
| 99 | 103 | constraints map[*surface]*constraint | |
| 100 | 104 | constraint *constraint // the active one | |
| 105 | + | positioners map[*server.Resource]*positioner | |
| 101 | 106 | } | |
| 102 | 107 | ||
| 103 | 108 | func removeItem[T comparable](list []T, v T) []T { | |
| @@ -131,7 +136,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 131 | 136 | d.Close() | |
| 132 | 137 | return nil, err | |
| 133 | 138 | } | |
| 134 | - | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 139 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 135 | 140 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 136 | 141 | if opts.Keymap != "" { | |
| 137 | 142 | fd, size, e := keymapFile(opts.Keymap) | |
| @@ -390,12 +395,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 390 | 395 | case ports.PointerButtonTo: | |
| 391 | 396 | // Releases may target the implicit-grab window after focus has moved. | |
| 392 | 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 | + | } | |
| 393 | 404 | for _, p := range s.windowPointers(w) { | |
| 394 | - | s.serial++ | |
| 395 | - | state := uint32(0) | |
| 396 | - | if c.Pressed { | |
| 397 | - | state = 1 | |
| 398 | - | } | |
| 399 | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | |
| 400 | 406 | pointerFrame(p) | |
| 401 | 407 | } | |
| @@ -425,6 +431,11 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 425 | 431 | if c.Key.Pressed { | |
| 426 | 432 | state = 1 | |
| 427 | 433 | } | |
| 434 | + | if c.Key.Pressed { | |
| 435 | + | if surf, _ := s.focusTarget(c.ID); surf != nil { | |
| 436 | + | s.press, s.pressClient = s.serial, surf.Client() | |
| 437 | + | } | |
| 438 | + | } | |
| 428 | 439 | for _, k := range keyboards { | |
| 429 | 440 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | |
| 430 | 441 | } | |
| @@ -435,9 +446,17 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 435 | 446 | s.sendModifiers(k) | |
| 436 | 447 | } | |
| 437 | 448 | } | |
| 449 | + | case ports.ConfigurePopup: | |
| 450 | + | if w := s.windows[c.ID]; w != nil && w.popup != nil { | |
| 451 | + | w.popup.configure(c) | |
| 452 | + | } | |
| 453 | + | case ports.ClosePopup: | |
| 454 | + | if w := s.windows[c.ID]; w != nil && w.popup != nil { | |
| 455 | + | w.popup.dismiss() | |
| 456 | + | } | |
| 438 | 457 | case ports.ConfigureWindow: | |
| 439 | 458 | w := s.windows[c.ID] | |
| 440 | - | if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() { | |
| 459 | + | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() { | |
| 441 | 460 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | |
| 442 | 461 | return | |
| 443 | 462 | } | |
| @@ -453,7 +472,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 453 | 472 | s.slotsPending = c.Pending | |
| 454 | 473 | case ports.CloseWindow: | |
| 455 | 474 | w := s.windows[c.ID] | |
| 456 | - | if w == nil || !w.toplevel.Resource.Alive() { | |
| 475 | + | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() { | |
| 457 | 476 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | |
| 458 | 477 | return | |
| 459 | 478 | } | |
| @@ -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 | ||
| @@ -98,6 +102,7 @@ type Server struct { | |||||
| 98 | relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1 | 102 | relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1 | ||
| 99 | constraints map[*surface]*constraint | 103 | constraints map[*surface]*constraint | ||
| 100 | constraint *constraint // the active one | 104 | constraint *constraint // the active one | ||
| 105 | + | positioners map[*server.Resource]*positioner | |||
| 101 | } | 106 | } | ||
| 102 | 107 | ||||
| 103 | func removeItem[T comparable](list []T, v T) []T { | 108 | func removeItem[T comparable](list []T, v T) []T { | ||
| @@ -131,7 +136,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 131 | d.Close() | 136 | d.Close() | ||
| 132 | return nil, err | 137 | return nil, err | ||
| 133 | } | 138 | } | ||
| 134 | - | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 139 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} |
| 135 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | 140 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | ||
| 136 | if opts.Keymap != "" { | 141 | if opts.Keymap != "" { | ||
| 137 | fd, size, e := keymapFile(opts.Keymap) | 142 | fd, size, e := keymapFile(opts.Keymap) | ||
| @@ -390,12 +395,13 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 390 | case ports.PointerButtonTo: | 395 | case ports.PointerButtonTo: | ||
| 391 | // Releases may target the implicit-grab window after focus has moved. | 396 | // Releases may target the implicit-grab window after focus has moved. | ||
| 392 | 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 | + | } | |||
| 393 | for _, p := range s.windowPointers(w) { | 404 | for _, p := range s.windowPointers(w) { | ||
| 394 | - | s.serial++ | |||
| 395 | - | state := uint32(0) | |||
| 396 | - | if c.Pressed { | |||
| 397 | - | state = 1 | |||
| 398 | - | } | |||
| 399 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | 405 | p.SendButton(s.serial, c.TimeMsec, c.Button, state) | ||
| 400 | pointerFrame(p) | 406 | pointerFrame(p) | ||
| 401 | } | 407 | } | ||
| @@ -425,6 +431,11 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 425 | if c.Key.Pressed { | 431 | if c.Key.Pressed { | ||
| 426 | state = 1 | 432 | state = 1 | ||
| 427 | } | 433 | } | ||
| 434 | + | if c.Key.Pressed { | |||
| 435 | + | if surf, _ := s.focusTarget(c.ID); surf != nil { | |||
| 436 | + | s.press, s.pressClient = s.serial, surf.Client() | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 428 | for _, k := range keyboards { | 439 | for _, k := range keyboards { | ||
| 429 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | 440 | k.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state) | ||
| 430 | } | 441 | } | ||
| @@ -435,9 +446,17 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 435 | s.sendModifiers(k) | 446 | s.sendModifiers(k) | ||
| 436 | } | 447 | } | ||
| 437 | } | 448 | } | ||
| 449 | + | case ports.ConfigurePopup: | |||
| 450 | + | if w := s.windows[c.ID]; w != nil && w.popup != nil { | |||
| 451 | + | w.popup.configure(c) | |||
| 452 | + | } | |||
| 453 | + | case ports.ClosePopup: | |||
| 454 | + | if w := s.windows[c.ID]; w != nil && w.popup != nil { | |||
| 455 | + | w.popup.dismiss() | |||
| 456 | + | } | |||
| 438 | case ports.ConfigureWindow: | 457 | case ports.ConfigureWindow: | ||
| 439 | w := s.windows[c.ID] | 458 | w := s.windows[c.ID] | ||
| 440 | - | if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() { | 459 | + | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() { |
| 441 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | 460 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | ||
| 442 | return | 461 | return | ||
| 443 | } | 462 | } | ||
| @@ -453,7 +472,7 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 453 | s.slotsPending = c.Pending | 472 | s.slotsPending = c.Pending | ||
| 454 | case ports.CloseWindow: | 473 | case ports.CloseWindow: | ||
| 455 | w := s.windows[c.ID] | 474 | w := s.windows[c.ID] | ||
| 456 | - | if w == nil || !w.toplevel.Resource.Alive() { | 475 | + | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() { |
| 457 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | 476 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | ||
| 458 | return | 477 | return | ||
| 459 | } | 478 | } | ||
M internal/adapters/wayland/surface.go +3 -0
| @@ -234,6 +234,9 @@ func (s *surface) Commit(*wayland.Surface) { | |||
| 234 | 234 | s.xdg.geometry, geometry = s.xdg.pendingGeometry, true | |
| 235 | 235 | } | |
| 236 | 236 | s.commitConstraint(geometry) | |
| 237 | + | if s.xdg != nil && s.xdg.window != nil { | |
| 238 | + | s.xdg.window.afterCommit() | |
| 239 | + | } | |
| 237 | 240 | if fresh || moved || geometry || s.sub.parent != nil { | |
| 238 | 241 | s.redraw() | |
| 239 | 242 | } | |
| @@ -234,6 +234,9 @@ func (s *surface) Commit(*wayland.Surface) { | |||||
| 234 | s.xdg.geometry, geometry = s.xdg.pendingGeometry, true | 234 | s.xdg.geometry, geometry = s.xdg.pendingGeometry, true | ||
| 235 | } | 235 | } | ||
| 236 | s.commitConstraint(geometry) | 236 | s.commitConstraint(geometry) | ||
| 237 | + | if s.xdg != nil && s.xdg.window != nil { | |||
| 238 | + | s.xdg.window.afterCommit() | |||
| 239 | + | } | |||
| 237 | if fresh || moved || geometry || s.sub.parent != nil { | 240 | if fresh || moved || geometry || s.sub.parent != nil { | ||
| 238 | s.redraw() | 241 | s.redraw() | ||
| 239 | } | 242 | } | ||
M internal/adapters/wayland/xdg.go +139 -38
| @@ -16,8 +16,12 @@ type wm struct{ server *Server } | |||
| 16 | 16 | ||
| 17 | 17 | func (wm) Destroy(*xdgshell.WmBase) {} | |
| 18 | 18 | func (wm) Pong(*xdgshell.WmBase, uint32) {} | |
| 19 | - | func (wm) CreatePositioner(r *xdgshell.WmBase, id uint32) { | |
| 20 | - | xdgshell.NewPositioner(r.Client(), r.Version(), id, positioner{}) | |
| 19 | + | func (m wm) CreatePositioner(r *xdgshell.WmBase, id uint32) { | |
| 20 | + | p := &positioner{} | |
| 21 | + | if res, err := xdgshell.NewPositioner(r.Client(), r.Version(), id, p); err == nil { | |
| 22 | + | m.server.positioners[res.Resource] = p | |
| 23 | + | res.OnDestroy = func() { delete(m.server.positioners, res.Resource) } | |
| 24 | + | } | |
| 21 | 25 | } | |
| 22 | 26 | func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | |
| 23 | 27 | if w == nil { | |
| @@ -31,7 +35,7 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | |||
| 31 | 35 | r.PostError(uint32(xdgshell.WmBaseErrorRole), "surface already has role") | |
| 32 | 36 | return | |
| 33 | 37 | } | |
| 34 | - | x := &xdgSurface{server: m.server, surface: state} | |
| 38 | + | x := &xdgSurface{wm: r, server: m.server, surface: state} | |
| 35 | 39 | resource, err := xdgshell.NewSurface(r.Client(), r.Version(), id, x) | |
| 36 | 40 | if err == nil { | |
| 37 | 41 | x.resource = resource | |
| @@ -42,27 +46,73 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | |||
| 42 | 46 | ||
| 43 | 47 | } | |
| 44 | 48 | ||
| 45 | - | type positioner struct{} | |
| 49 | + | // positioner is an xdg_positioner: popups copy it when they use it. | |
| 50 | + | type positioner struct { | |
| 51 | + | p ports.Positioner | |
| 52 | + | sizeSet, anchorSet bool | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (*positioner) Destroy(*xdgshell.Positioner) {} | |
| 56 | + | func (p *positioner) SetSize(r *xdgshell.Positioner, w, h int32) { | |
| 57 | + | if w <= 0 || h <= 0 { | |
| 58 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "size must be positive") | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | p.p.Width, p.p.Height, p.sizeSet = int(w), int(h), true | |
| 62 | + | } | |
| 63 | + | func (p *positioner) SetAnchorRect(r *xdgshell.Positioner, x, y, w, h int32) { | |
| 64 | + | if w < 0 || h < 0 { | |
| 65 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "anchor rect size must not be negative") | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | p.p.AnchorRect, p.anchorSet = ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, true | |
| 69 | + | } | |
| 70 | + | func (p *positioner) SetAnchor(r *xdgshell.Positioner, v uint32) { | |
| 71 | + | if v > ports.EdgeBottomRight { | |
| 72 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "invalid anchor") | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | p.p.Anchor = v | |
| 76 | + | } | |
| 77 | + | func (p *positioner) SetGravity(r *xdgshell.Positioner, v uint32) { | |
| 78 | + | if v > ports.EdgeBottomRight { | |
| 79 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "invalid gravity") | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | p.p.Gravity = v | |
| 83 | + | } | |
| 84 | + | func (p *positioner) SetConstraintAdjustment(_ *xdgshell.Positioner, v uint32) { p.p.Adjust = v } | |
| 85 | + | func (p *positioner) SetOffset(_ *xdgshell.Positioner, x, y int32) { | |
| 86 | + | p.p.OffsetX, p.p.OffsetY = int(x), int(y) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Reactive popups, parent size and parent configure hints are not used: | |
| 90 | + | // core places popups when they are created or repositioned. | |
| 91 | + | func (*positioner) SetReactive(*xdgshell.Positioner) {} | |
| 92 | + | func (*positioner) SetParentSize(*xdgshell.Positioner, int32, int32) {} | |
| 93 | + | func (*positioner) SetParentConfigure(*xdgshell.Positioner, uint32) {} | |
| 46 | 94 | ||
| 47 | - | func (positioner) Destroy(*xdgshell.Positioner) {} | |
| 48 | - | func (positioner) SetSize(*xdgshell.Positioner, int32, int32) {} | |
| 49 | - | func (positioner) SetAnchorRect(*xdgshell.Positioner, int32, int32, int32, int32) {} | |
| 50 | - | func (positioner) SetAnchor(*xdgshell.Positioner, uint32) {} | |
| 51 | - | func (positioner) SetGravity(*xdgshell.Positioner, uint32) {} | |
| 52 | - | func (positioner) SetConstraintAdjustment(*xdgshell.Positioner, uint32) {} | |
| 53 | - | func (positioner) SetOffset(*xdgshell.Positioner, int32, int32) {} | |
| 54 | - | func (positioner) SetReactive(*xdgshell.Positioner) {} | |
| 55 | - | func (positioner) SetParentSize(*xdgshell.Positioner, int32, int32) {} | |
| 56 | - | func (positioner) SetParentConfigure(*xdgshell.Positioner, uint32) {} | |
| 95 | + | // positionerOf returns a complete positioner, or posts invalid_positioner. | |
| 96 | + | func (s *Server) positionerOf(wm *xdgshell.WmBase, p *xdgshell.Positioner) (ports.Positioner, bool) { | |
| 97 | + | if p != nil && p.Resource != nil { | |
| 98 | + | if state := s.positioners[p.Resource]; state != nil && state.sizeSet && state.anchorSet { | |
| 99 | + | return state.p, true | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if wm != nil { | |
| 103 | + | wm.PostError(uint32(xdgshell.WmBaseErrorInvalidPositioner), "incomplete positioner") | |
| 104 | + | } | |
| 105 | + | return ports.Positioner{}, false | |
| 106 | + | } | |
| 57 | 107 | ||
| 58 | 108 | type xdgSurface struct { | |
| 109 | + | wm *xdgshell.WmBase | |
| 59 | 110 | resource *xdgshell.Surface | |
| 60 | 111 | server *Server | |
| 61 | 112 | surface *surface | |
| 62 | 113 | configured bool | |
| 63 | 114 | acked bool | |
| 64 | 115 | window *window | |
| 65 | - | popup *xdgshell.Popup | |
| 66 | 116 | serials []uint32 | |
| 67 | 117 | // geometry is the committed window geometry: the visible window inside | |
| 68 | 118 | // client shadows. Empty means the whole surface. | |
| @@ -70,17 +120,25 @@ type xdgSurface struct { | |||
| 70 | 120 | } | |
| 71 | 121 | ||
| 72 | 122 | func (x *xdgSurface) Destroy(r *xdgshell.Surface) { | |
| 73 | - | if x.window != nil || x.popup != nil { | |
| 123 | + | if x.window != nil { | |
| 74 | 124 | r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists") | |
| 75 | 125 | } | |
| 76 | 126 | } | |
| 77 | 127 | ||
| 128 | + | // window is an xdg_toplevel, or an xdg_popup when popup is set. | |
| 78 | 129 | type window struct { | |
| 79 | 130 | id ports.WindowID | |
| 80 | 131 | toplevel *xdgshell.Toplevel | |
| 132 | + | popup *popup | |
| 81 | 133 | xdg *xdgSurface | |
| 82 | 134 | appID, title string | |
| 83 | 135 | mapped bool | |
| 136 | + | // parent is set_parent (dialogs); min and max are the size hints. | |
| 137 | + | parent *window | |
| 138 | + | minW, minH int32 | |
| 139 | + | maxW, maxH int32 | |
| 140 | + | floating bool | |
| 141 | + | floatW, floatH int // size last sent to core | |
| 84 | 142 | // last is the most recent core configure, resent when decoration mode changes. | |
| 85 | 143 | last ports.ConfigureWindow | |
| 86 | 144 | hasLast bool | |
| @@ -96,7 +154,7 @@ func (w *window) sendConfigure() { | |||
| 96 | 154 | if c.Activated { | |
| 97 | 155 | states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated)) | |
| 98 | 156 | } | |
| 99 | - | if !c.Fullscreen && w.toplevel.Version() >= 2 { | |
| 157 | + | if !c.Fullscreen && !c.Floating && w.toplevel.Version() >= 2 { | |
| 100 | 158 | // Tiled: the client must use exactly this size (no CSD shadows or rounding). | |
| 101 | 159 | for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} { | |
| 102 | 160 | states = binary.LittleEndian.AppendUint32(states, uint32(st)) | |
| @@ -127,7 +185,7 @@ func (w *window) unmap() { | |||
| 127 | 185 | w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}) | |
| 128 | 186 | } | |
| 129 | 187 | func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |
| 130 | - | if x.window != nil || x.popup != nil { | |
| 188 | + | if x.window != nil { | |
| 131 | 189 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | |
| 132 | 190 | return | |
| 133 | 191 | } | |
| @@ -154,8 +212,10 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 154 | 212 | } else if buffer && !w.mapped && x.acked { | |
| 155 | 213 | w.mapped = true | |
| 156 | 214 | slot := x.server.slotToken(r.Client()) | |
| 157 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()}) | |
| 158 | - | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | |
| 215 | + | // Its size comes with the commit, in WindowResized (afterCommit). | |
| 216 | + | w.floating = w.floats() | |
| 217 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH}) | |
| 218 | + | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | |
| 159 | 219 | } else if !buffer && w.mapped { | |
| 160 | 220 | w.unmap() | |
| 161 | 221 | } | |
| @@ -173,16 +233,12 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 173 | 233 | x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{} | |
| 174 | 234 | } | |
| 175 | 235 | } | |
| 176 | - | func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) { | |
| 177 | - | if x.window != nil || x.popup != nil { | |
| 236 | + | func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, parent *xdgshell.Surface, pos *xdgshell.Positioner) { | |
| 237 | + | if x.window != nil { | |
| 178 | 238 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | |
| 179 | 239 | return | |
| 180 | 240 | } | |
| 181 | - | if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil { | |
| 182 | - | x.popup = p | |
| 183 | - | p.OnDestroy = func() { x.popup = nil } | |
| 184 | - | p.SendPopupDone() | |
| 185 | - | } | |
| 241 | + | x.server.newPopup(x, r, id, parent, pos) | |
| 186 | 242 | } | |
| 187 | 243 | ||
| 188 | 244 | // SetWindowGeometry sets the visible window inside the surface; it | |
| @@ -212,9 +268,19 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |||
| 212 | 268 | ||
| 213 | 269 | type top struct{ w *window } | |
| 214 | 270 | ||
| 215 | - | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 216 | - | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} | |
| 217 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |
| 271 | + | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 272 | + | func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) { | |
| 273 | + | t.w.parent = nil | |
| 274 | + | if parent == nil { | |
| 275 | + | return | |
| 276 | + | } | |
| 277 | + | for _, w := range t.w.xdg.server.windows { | |
| 278 | + | if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w { | |
| 279 | + | t.w.parent = w | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |
| 218 | 284 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |
| 219 | 285 | if appID == t.w.appID { | |
| 220 | 286 | return | |
| @@ -227,10 +293,22 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||
| 227 | 293 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | |
| 228 | 294 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | |
| 229 | 295 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | |
| 230 | - | func (top) SetMaxSize(*xdgshell.Toplevel, int32, int32) {} | |
| 231 | - | func (top) SetMinSize(*xdgshell.Toplevel, int32, int32) {} | |
| 232 | - | func (top) SetMaximized(*xdgshell.Toplevel) {} | |
| 233 | - | func (top) UnsetMaximized(*xdgshell.Toplevel) {} | |
| 296 | + | func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) { | |
| 297 | + | if w < 0 || h < 0 { | |
| 298 | + | r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative max size") | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | t.w.maxW, t.w.maxH = w, h | |
| 302 | + | } | |
| 303 | + | func (t top) SetMinSize(r *xdgshell.Toplevel, w, h int32) { | |
| 304 | + | if w < 0 || h < 0 { | |
| 305 | + | r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative min size") | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | t.w.minW, t.w.minH = w, h | |
| 309 | + | } | |
| 310 | + | func (top) SetMaximized(*xdgshell.Toplevel) {} | |
| 311 | + | func (top) UnsetMaximized(*xdgshell.Toplevel) {} | |
| 234 | 312 | func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) { | |
| 235 | 313 | if t.w.mapped { | |
| 236 | 314 | t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true}) | |
| @@ -243,11 +321,34 @@ func (t top) UnsetFullscreen(*xdgshell.Toplevel) { | |||
| 243 | 321 | } | |
| 244 | 322 | func (top) SetMinimized(*xdgshell.Toplevel) {} | |
| 245 | 323 | ||
| 246 | - | type popup struct{} | |
| 324 | + | // afterCommit runs once the commit applied content and geometry: a | |
| 325 | + | // floating window keeps the size it draws. | |
| 326 | + | func (w *window) afterCommit() { | |
| 327 | + | if !w.mapped || !w.floating { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | |
| 331 | + | w.floatW, w.floatH = fw, fh | |
| 332 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // floats reports whether the toplevel should float over the columns, as | |
| 337 | + | // on niri: dialogs (a parent) and fixed-size windows such as splash | |
| 338 | + | // screens. | |
| 339 | + | func (w *window) floats() bool { | |
| 340 | + | fixed := w.minW > 0 && w.minH > 0 && w.minW == w.maxW && w.minH == w.maxH | |
| 341 | + | return w.parent != nil || fixed | |
| 342 | + | } | |
| 247 | 343 | ||
| 248 | - | func (popup) Destroy(*xdgshell.Popup) {} | |
| 249 | - | func (popup) Grab(*xdgshell.Popup, *wayland.Seat, uint32) {} | |
| 250 | - | func (popup) Reposition(*xdgshell.Popup, *xdgshell.Positioner, uint32) {} | |
| 344 | + | // size is the window geometry size, else the surface size, in logical pixels. | |
| 345 | + | func (w *window) size() (int, int) { | |
| 346 | + | if g := w.xdg.geometry; g.W > 0 && g.H > 0 { | |
| 347 | + | return g.W, g.H | |
| 348 | + | } | |
| 349 | + | c := w.xdg.surface.content | |
| 350 | + | return c.LogicalW, c.LogicalH | |
| 351 | + | } | |
| 251 | 352 | ||
| 252 | 353 | // surfacePoint turns window coordinates (core's, from the geometry origin) | |
| 253 | 354 | // into surface coordinates. | |
| @@ -16,8 +16,12 @@ type wm struct{ server *Server } | |||||
| 16 | 16 | ||||
| 17 | func (wm) Destroy(*xdgshell.WmBase) {} | 17 | func (wm) Destroy(*xdgshell.WmBase) {} | ||
| 18 | func (wm) Pong(*xdgshell.WmBase, uint32) {} | 18 | func (wm) Pong(*xdgshell.WmBase, uint32) {} | ||
| 19 | - | func (wm) CreatePositioner(r *xdgshell.WmBase, id uint32) { | 19 | + | func (m wm) CreatePositioner(r *xdgshell.WmBase, id uint32) { |
| 20 | - | xdgshell.NewPositioner(r.Client(), r.Version(), id, positioner{}) | 20 | + | p := &positioner{} |
| 21 | + | if res, err := xdgshell.NewPositioner(r.Client(), r.Version(), id, p); err == nil { | |||
| 22 | + | m.server.positioners[res.Resource] = p | |||
| 23 | + | res.OnDestroy = func() { delete(m.server.positioners, res.Resource) } | |||
| 24 | + | } | |||
| 21 | } | 25 | } | ||
| 22 | func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | 26 | func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | ||
| 23 | if w == nil { | 27 | if w == nil { | ||
| @@ -31,7 +35,7 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | |||||
| 31 | r.PostError(uint32(xdgshell.WmBaseErrorRole), "surface already has role") | 35 | r.PostError(uint32(xdgshell.WmBaseErrorRole), "surface already has role") | ||
| 32 | return | 36 | return | ||
| 33 | } | 37 | } | ||
| 34 | - | x := &xdgSurface{server: m.server, surface: state} | 38 | + | x := &xdgSurface{wm: r, server: m.server, surface: state} |
| 35 | resource, err := xdgshell.NewSurface(r.Client(), r.Version(), id, x) | 39 | resource, err := xdgshell.NewSurface(r.Client(), r.Version(), id, x) | ||
| 36 | if err == nil { | 40 | if err == nil { | ||
| 37 | x.resource = resource | 41 | x.resource = resource | ||
| @@ -42,27 +46,73 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) { | |||||
| 42 | 46 | ||||
| 43 | } | 47 | } | ||
| 44 | 48 | ||||
| 45 | - | type positioner struct{} | 49 | + | // positioner is an xdg_positioner: popups copy it when they use it. |
| 50 | + | type positioner struct { | |||
| 51 | + | p ports.Positioner | |||
| 52 | + | sizeSet, anchorSet bool | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (*positioner) Destroy(*xdgshell.Positioner) {} | |||
| 56 | + | func (p *positioner) SetSize(r *xdgshell.Positioner, w, h int32) { | |||
| 57 | + | if w <= 0 || h <= 0 { | |||
| 58 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "size must be positive") | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | p.p.Width, p.p.Height, p.sizeSet = int(w), int(h), true | |||
| 62 | + | } | |||
| 63 | + | func (p *positioner) SetAnchorRect(r *xdgshell.Positioner, x, y, w, h int32) { | |||
| 64 | + | if w < 0 || h < 0 { | |||
| 65 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "anchor rect size must not be negative") | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | p.p.AnchorRect, p.anchorSet = ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, true | |||
| 69 | + | } | |||
| 70 | + | func (p *positioner) SetAnchor(r *xdgshell.Positioner, v uint32) { | |||
| 71 | + | if v > ports.EdgeBottomRight { | |||
| 72 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "invalid anchor") | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | p.p.Anchor = v | |||
| 76 | + | } | |||
| 77 | + | func (p *positioner) SetGravity(r *xdgshell.Positioner, v uint32) { | |||
| 78 | + | if v > ports.EdgeBottomRight { | |||
| 79 | + | r.PostError(uint32(xdgshell.PositionerErrorInvalidInput), "invalid gravity") | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | p.p.Gravity = v | |||
| 83 | + | } | |||
| 84 | + | func (p *positioner) SetConstraintAdjustment(_ *xdgshell.Positioner, v uint32) { p.p.Adjust = v } | |||
| 85 | + | func (p *positioner) SetOffset(_ *xdgshell.Positioner, x, y int32) { | |||
| 86 | + | p.p.OffsetX, p.p.OffsetY = int(x), int(y) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Reactive popups, parent size and parent configure hints are not used: | |||
| 90 | + | // core places popups when they are created or repositioned. | |||
| 91 | + | func (*positioner) SetReactive(*xdgshell.Positioner) {} | |||
| 92 | + | func (*positioner) SetParentSize(*xdgshell.Positioner, int32, int32) {} | |||
| 93 | + | func (*positioner) SetParentConfigure(*xdgshell.Positioner, uint32) {} | |||
| 46 | 94 | ||||
| 47 | - | func (positioner) Destroy(*xdgshell.Positioner) {} | 95 | + | // positionerOf returns a complete positioner, or posts invalid_positioner. |
| 48 | - | func (positioner) SetSize(*xdgshell.Positioner, int32, int32) {} | 96 | + | func (s *Server) positionerOf(wm *xdgshell.WmBase, p *xdgshell.Positioner) (ports.Positioner, bool) { |
| 49 | - | func (positioner) SetAnchorRect(*xdgshell.Positioner, int32, int32, int32, int32) {} | 97 | + | if p != nil && p.Resource != nil { |
| 50 | - | func (positioner) SetAnchor(*xdgshell.Positioner, uint32) {} | 98 | + | if state := s.positioners[p.Resource]; state != nil && state.sizeSet && state.anchorSet { |
| 51 | - | func (positioner) SetGravity(*xdgshell.Positioner, uint32) {} | 99 | + | return state.p, true |
| 52 | - | func (positioner) SetConstraintAdjustment(*xdgshell.Positioner, uint32) {} | 100 | + | } |
| 53 | - | func (positioner) SetOffset(*xdgshell.Positioner, int32, int32) {} | 101 | + | } |
| 54 | - | func (positioner) SetReactive(*xdgshell.Positioner) {} | 102 | + | if wm != nil { |
| 55 | - | func (positioner) SetParentSize(*xdgshell.Positioner, int32, int32) {} | 103 | + | wm.PostError(uint32(xdgshell.WmBaseErrorInvalidPositioner), "incomplete positioner") |
| 56 | - | func (positioner) SetParentConfigure(*xdgshell.Positioner, uint32) {} | 104 | + | } |
| 105 | + | return ports.Positioner{}, false | |||
| 106 | + | } | |||
| 57 | 107 | ||||
| 58 | type xdgSurface struct { | 108 | type xdgSurface struct { | ||
| 109 | + | wm *xdgshell.WmBase | |||
| 59 | resource *xdgshell.Surface | 110 | resource *xdgshell.Surface | ||
| 60 | server *Server | 111 | server *Server | ||
| 61 | surface *surface | 112 | surface *surface | ||
| 62 | configured bool | 113 | configured bool | ||
| 63 | acked bool | 114 | acked bool | ||
| 64 | window *window | 115 | window *window | ||
| 65 | - | popup *xdgshell.Popup | |||
| 66 | serials []uint32 | 116 | serials []uint32 | ||
| 67 | // geometry is the committed window geometry: the visible window inside | 117 | // geometry is the committed window geometry: the visible window inside | ||
| 68 | // client shadows. Empty means the whole surface. | 118 | // client shadows. Empty means the whole surface. | ||
| @@ -70,17 +120,25 @@ type xdgSurface struct { | |||||
| 70 | } | 120 | } | ||
| 71 | 121 | ||||
| 72 | func (x *xdgSurface) Destroy(r *xdgshell.Surface) { | 122 | func (x *xdgSurface) Destroy(r *xdgshell.Surface) { | ||
| 73 | - | if x.window != nil || x.popup != nil { | 123 | + | if x.window != nil { |
| 74 | r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists") | 124 | r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists") | ||
| 75 | } | 125 | } | ||
| 76 | } | 126 | } | ||
| 77 | 127 | ||||
| 128 | + | // window is an xdg_toplevel, or an xdg_popup when popup is set. | |||
| 78 | type window struct { | 129 | type window struct { | ||
| 79 | id ports.WindowID | 130 | id ports.WindowID | ||
| 80 | toplevel *xdgshell.Toplevel | 131 | toplevel *xdgshell.Toplevel | ||
| 132 | + | popup *popup | |||
| 81 | xdg *xdgSurface | 133 | xdg *xdgSurface | ||
| 82 | appID, title string | 134 | appID, title string | ||
| 83 | mapped bool | 135 | mapped bool | ||
| 136 | + | // parent is set_parent (dialogs); min and max are the size hints. | |||
| 137 | + | parent *window | |||
| 138 | + | minW, minH int32 | |||
| 139 | + | maxW, maxH int32 | |||
| 140 | + | floating bool | |||
| 141 | + | floatW, floatH int // size last sent to core | |||
| 84 | // last is the most recent core configure, resent when decoration mode changes. | 142 | // last is the most recent core configure, resent when decoration mode changes. | ||
| 85 | last ports.ConfigureWindow | 143 | last ports.ConfigureWindow | ||
| 86 | hasLast bool | 144 | hasLast bool | ||
| @@ -96,7 +154,7 @@ func (w *window) sendConfigure() { | |||||
| 96 | if c.Activated { | 154 | if c.Activated { | ||
| 97 | states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated)) | 155 | states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated)) | ||
| 98 | } | 156 | } | ||
| 99 | - | if !c.Fullscreen && w.toplevel.Version() >= 2 { | 157 | + | if !c.Fullscreen && !c.Floating && w.toplevel.Version() >= 2 { |
| 100 | // Tiled: the client must use exactly this size (no CSD shadows or rounding). | 158 | // Tiled: the client must use exactly this size (no CSD shadows or rounding). | ||
| 101 | for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} { | 159 | for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} { | ||
| 102 | states = binary.LittleEndian.AppendUint32(states, uint32(st)) | 160 | states = binary.LittleEndian.AppendUint32(states, uint32(st)) | ||
| @@ -127,7 +185,7 @@ func (w *window) unmap() { | |||||
| 127 | w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}) | 185 | w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}) | ||
| 128 | } | 186 | } | ||
| 129 | func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | 187 | func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | ||
| 130 | - | if x.window != nil || x.popup != nil { | 188 | + | if x.window != nil { |
| 131 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | 189 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | ||
| 132 | return | 190 | return | ||
| 133 | } | 191 | } | ||
| @@ -154,8 +212,10 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 154 | } else if buffer && !w.mapped && x.acked { | 212 | } else if buffer && !w.mapped && x.acked { | ||
| 155 | w.mapped = true | 213 | w.mapped = true | ||
| 156 | slot := x.server.slotToken(r.Client()) | 214 | slot := x.server.slotToken(r.Client()) | ||
| 157 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()}) | 215 | + | // Its size comes with the commit, in WindowResized (afterCommit). |
| 158 | - | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | 216 | + | w.floating = w.floats() |
| 217 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID(), Floating: w.floating, Width: w.floatW, Height: w.floatH}) | |||
| 218 | + | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Bool("floating", w.floating).Msg("window mapped") | |||
| 159 | } else if !buffer && w.mapped { | 219 | } else if !buffer && w.mapped { | ||
| 160 | w.unmap() | 220 | w.unmap() | ||
| 161 | } | 221 | } | ||
| @@ -173,16 +233,12 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 173 | x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{} | 233 | x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{} | ||
| 174 | } | 234 | } | ||
| 175 | } | 235 | } | ||
| 176 | - | func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) { | 236 | + | func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, parent *xdgshell.Surface, pos *xdgshell.Positioner) { |
| 177 | - | if x.window != nil || x.popup != nil { | 237 | + | if x.window != nil { |
| 178 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | 238 | r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed") | ||
| 179 | return | 239 | return | ||
| 180 | } | 240 | } | ||
| 181 | - | if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil { | 241 | + | x.server.newPopup(x, r, id, parent, pos) |
| 182 | - | x.popup = p | |||
| 183 | - | p.OnDestroy = func() { x.popup = nil } | |||
| 184 | - | p.SendPopupDone() | |||
| 185 | - | } | |||
| 186 | } | 242 | } | ||
| 187 | 243 | ||||
| 188 | // SetWindowGeometry sets the visible window inside the surface; it | 244 | // SetWindowGeometry sets the visible window inside the surface; it | ||
| @@ -212,9 +268,19 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |||||
| 212 | 268 | ||||
| 213 | type top struct{ w *window } | 269 | type top struct{ w *window } | ||
| 214 | 270 | ||||
| 215 | - | func (top) Destroy(*xdgshell.Toplevel) {} | 271 | + | func (top) Destroy(*xdgshell.Toplevel) {} |
| 216 | - | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} | 272 | + | func (t top) SetParent(_ *xdgshell.Toplevel, parent *xdgshell.Toplevel) { |
| 217 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | 273 | + | t.w.parent = nil |
| 274 | + | if parent == nil { | |||
| 275 | + | return | |||
| 276 | + | } | |||
| 277 | + | for _, w := range t.w.xdg.server.windows { | |||
| 278 | + | if w.toplevel != nil && w.toplevel.Resource == parent.Resource && w != t.w { | |||
| 279 | + | t.w.parent = w | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |||
| 218 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | 284 | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | ||
| 219 | if appID == t.w.appID { | 285 | if appID == t.w.appID { | ||
| 220 | return | 286 | return | ||
| @@ -227,10 +293,22 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |||||
| 227 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | 293 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | ||
| 228 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | 294 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | ||
| 229 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | 295 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | ||
| 230 | - | func (top) SetMaxSize(*xdgshell.Toplevel, int32, int32) {} | 296 | + | func (t top) SetMaxSize(r *xdgshell.Toplevel, w, h int32) { |
| 231 | - | func (top) SetMinSize(*xdgshell.Toplevel, int32, int32) {} | 297 | + | if w < 0 || h < 0 { |
| 232 | - | func (top) SetMaximized(*xdgshell.Toplevel) {} | 298 | + | r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative max size") |
| 233 | - | func (top) UnsetMaximized(*xdgshell.Toplevel) {} | 299 | + | return |
| 300 | + | } | |||
| 301 | + | t.w.maxW, t.w.maxH = w, h | |||
| 302 | + | } | |||
| 303 | + | func (t top) SetMinSize(r *xdgshell.Toplevel, w, h int32) { | |||
| 304 | + | if w < 0 || h < 0 { | |||
| 305 | + | r.PostError(uint32(xdgshell.ToplevelErrorInvalidSize), "negative min size") | |||
| 306 | + | return | |||
| 307 | + | } | |||
| 308 | + | t.w.minW, t.w.minH = w, h | |||
| 309 | + | } | |||
| 310 | + | func (top) SetMaximized(*xdgshell.Toplevel) {} | |||
| 311 | + | func (top) UnsetMaximized(*xdgshell.Toplevel) {} | |||
| 234 | func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) { | 312 | func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) { | ||
| 235 | if t.w.mapped { | 313 | if t.w.mapped { | ||
| 236 | t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true}) | 314 | t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true}) | ||
| @@ -243,11 +321,34 @@ func (t top) UnsetFullscreen(*xdgshell.Toplevel) { | |||||
| 243 | } | 321 | } | ||
| 244 | func (top) SetMinimized(*xdgshell.Toplevel) {} | 322 | func (top) SetMinimized(*xdgshell.Toplevel) {} | ||
| 245 | 323 | ||||
| 246 | - | type popup struct{} | 324 | + | // afterCommit runs once the commit applied content and geometry: a |
| 325 | + | // floating window keeps the size it draws. | |||
| 326 | + | func (w *window) afterCommit() { | |||
| 327 | + | if !w.mapped || !w.floating { | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | |||
| 331 | + | w.floatW, w.floatH = fw, fh | |||
| 332 | + | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // floats reports whether the toplevel should float over the columns, as | |||
| 337 | + | // on niri: dialogs (a parent) and fixed-size windows such as splash | |||
| 338 | + | // screens. | |||
| 339 | + | func (w *window) floats() bool { | |||
| 340 | + | fixed := w.minW > 0 && w.minH > 0 && w.minW == w.maxW && w.minH == w.maxH | |||
| 341 | + | return w.parent != nil || fixed | |||
| 342 | + | } | |||
| 247 | 343 | ||||
| 248 | - | func (popup) Destroy(*xdgshell.Popup) {} | 344 | + | // size is the window geometry size, else the surface size, in logical pixels. |
| 249 | - | func (popup) Grab(*xdgshell.Popup, *wayland.Seat, uint32) {} | 345 | + | func (w *window) size() (int, int) { |
| 250 | - | func (popup) Reposition(*xdgshell.Popup, *xdgshell.Positioner, uint32) {} | 346 | + | if g := w.xdg.geometry; g.W > 0 && g.H > 0 { |
| 347 | + | return g.W, g.H | |||
| 348 | + | } | |||
| 349 | + | c := w.xdg.surface.content | |||
| 350 | + | return c.LogicalW, c.LogicalH | |||
| 351 | + | } | |||
| 251 | 352 | ||||
| 252 | // surfacePoint turns window coordinates (core's, from the geometry origin) | 353 | // surfacePoint turns window coordinates (core's, from the geometry origin) | ||
| 253 | // into surface coordinates. | 354 | // into surface coordinates. | ||
M internal/core/core.go +63 -8
| @@ -78,6 +78,9 @@ type Core struct { | |||
| 78 | 78 | // clients holds the app ID and PID of mapped windows, for State. | |
| 79 | 79 | clients map[WindowID]ports.WindowMapped | |
| 80 | 80 | sentState ports.State | |
| 81 | + | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 82 | + | popups map[WindowID]*popupState | |
| 83 | + | popupOrder []WindowID | |
| 81 | 84 | } | |
| 82 | 85 | ||
| 83 | 86 | func keyName(s string) string { | |
| @@ -260,7 +263,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 260 | 263 | return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1") | |
| 261 | 264 | } | |
| 262 | 265 | // A placeholder screen holds windows until the first output arrives. | |
| 263 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 266 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 264 | 267 | if err := c.apply(cfg); err != nil { | |
| 265 | 268 | return nil, err | |
| 266 | 269 | } | |
| @@ -314,6 +317,10 @@ func (c *Core) keyboardFocus() WindowID { | |||
| 314 | 317 | if layer != 0 { | |
| 315 | 318 | return layer | |
| 316 | 319 | } | |
| 320 | + | // A menu with a grab takes the keyboard until it closes. | |
| 321 | + | if g := c.grabFocus(); g != 0 { | |
| 322 | + | return g | |
| 323 | + | } | |
| 317 | 324 | id, _ := c.cur().mon.Focused() | |
| 318 | 325 | return id | |
| 319 | 326 | } | |
| @@ -337,6 +344,9 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||
| 337 | 344 | } | |
| 338 | 345 | ||
| 339 | 346 | func (c *Core) publish(ctx context.Context) error { | |
| 347 | + | if err := c.closeHiddenPopups(ctx); err != nil { | |
| 348 | + | return err | |
| 349 | + | } | |
| 340 | 350 | // Clients learn outputs and scales before the configures sized for them. | |
| 341 | 351 | if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) { | |
| 342 | 352 | if err := c.command(ctx, v); err != nil { | |
| @@ -372,6 +382,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 372 | 382 | // Only the focused output shows the focused border. | |
| 373 | 383 | focused := p.Focused && i == c.focusScreen | |
| 374 | 384 | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | |
| 385 | + | floating := p.Floating | |
| 375 | 386 | if p.Hidden { | |
| 376 | 387 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | |
| 377 | 388 | old.Activated, old.Output = false, "" | |
| @@ -383,7 +394,11 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 383 | 394 | continue | |
| 384 | 395 | } | |
| 385 | 396 | r := c.clientRect(p) | |
| 386 | - | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Output: sc.name()} | |
| 397 | + | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name()} | |
| 398 | + | if v.Floating { | |
| 399 | + | // A floating window picks its own size. | |
| 400 | + | v.Width, v.Height = 0, 0 | |
| 401 | + | } | |
| 387 | 402 | if old, ok := c.sent[p.ID]; !ok || old != v { | |
| 388 | 403 | if err := c.command(ctx, v); err != nil { | |
| 389 | 404 | return err | |
| @@ -391,6 +406,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 391 | 406 | c.sent[p.ID] = v | |
| 392 | 407 | } | |
| 393 | 408 | } | |
| 409 | + | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 394 | 410 | scenes = append(scenes, scene) | |
| 395 | 411 | } | |
| 396 | 412 | for id := range c.sent { | |
| @@ -496,17 +512,26 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 496 | 512 | return 0, 0, 0 | |
| 497 | 513 | } | |
| 498 | 514 | lx, ly := x-float64(o.X), y-float64(o.Y) | |
| 515 | + | sc := c.screens[c.screenIndex(o.Info.Name)] | |
| 516 | + | // Popups are above everything but fullscreen covers nothing of them. | |
| 517 | + | if id, px, py := c.popupAt(sc, lx, ly); id != 0 { | |
| 518 | + | return id, px, py | |
| 519 | + | } | |
| 499 | 520 | var id WindowID | |
| 500 | 521 | var sx, sy float64 | |
| 501 | - | for _, p := range c.screens[c.screenIndex(o.Info.Name)].mon.Layout() { | |
| 522 | + | full := false | |
| 523 | + | for _, p := range sc.mon.Layout() { | |
| 502 | 524 | r := c.clientRect(p) | |
| 503 | 525 | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |
| 504 | - | if id == 0 || p.Fullscreen { | |
| 505 | - | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | |
| 526 | + | // Floating windows come last in the layout and are on top, | |
| 527 | + | // even of a fullscreen window; nothing else is. | |
| 528 | + | if full && !p.Floating { | |
| 529 | + | continue | |
| 506 | 530 | } | |
| 507 | - | if p.Fullscreen { | |
| 508 | - | break | |
| 531 | + | if id == 0 || p.Fullscreen || p.Floating { | |
| 532 | + | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | |
| 509 | 533 | } | |
| 534 | + | full = full || p.Fullscreen | |
| 510 | 535 | } | |
| 511 | 536 | } | |
| 512 | 537 | return id, sx, sy | |
| @@ -541,17 +566,41 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 541 | 566 | c.setLayers(v.Layers) | |
| 542 | 567 | case ports.WindowMapped: | |
| 543 | 568 | c.clients[v.ID] = v | |
| 544 | - | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |
| 569 | + | if v.Floating { | |
| 570 | + | if s, _ := c.screenOf(v.ID); s == nil { | |
| 571 | + | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |
| 572 | + | } | |
| 573 | + | } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |
| 545 | 574 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 546 | 575 | c.cur().mon.AddWindow(v.ID) | |
| 547 | 576 | } | |
| 548 | 577 | } | |
| 578 | + | case ports.WindowResized: | |
| 579 | + | if _, w := c.screenOf(v.ID); w != nil { | |
| 580 | + | w.ResizeFloating(v.ID, v.Width, v.Height) | |
| 581 | + | } | |
| 582 | + | case ports.PopupRequest: | |
| 583 | + | if err := c.placePopup(ctx, v); err != nil { | |
| 584 | + | return nil | |
| 585 | + | } | |
| 586 | + | case ports.PopupMapped: | |
| 587 | + | if p := c.popups[v.ID]; p != nil { | |
| 588 | + | p.mapped = true | |
| 589 | + | } | |
| 549 | 590 | case ports.WindowAppID: | |
| 550 | 591 | if info, ok := c.clients[v.ID]; ok { | |
| 551 | 592 | info.AppID = v.AppID | |
| 552 | 593 | c.clients[v.ID] = info | |
| 553 | 594 | } | |
| 554 | 595 | case ports.WindowUnmapped: | |
| 596 | + | if c.popups[v.ID] != nil { | |
| 597 | + | if err := c.dropPopup(ctx, v.ID); err != nil { | |
| 598 | + | return nil | |
| 599 | + | } | |
| 600 | + | } | |
| 601 | + | if err := c.closePopupsOf(ctx, v.ID); err != nil { | |
| 602 | + | return nil | |
| 603 | + | } | |
| 555 | 604 | delete(c.clients, v.ID) | |
| 556 | 605 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 557 | 606 | s.mon.RemoveWindow(v.ID) | |
| @@ -655,6 +704,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 655 | 704 | if c.grab != 0 { | |
| 656 | 705 | id = c.grab | |
| 657 | 706 | } | |
| 707 | + | if v.Pressed && len(c.buttons) == 0 { | |
| 708 | + | // A click outside an open menu closes it. | |
| 709 | + | if err := c.dismissGrabs(ctx, id); err != nil { | |
| 710 | + | return nil | |
| 711 | + | } | |
| 712 | + | } | |
| 658 | 713 | if v.Pressed { | |
| 659 | 714 | if len(c.buttons) == 0 { | |
| 660 | 715 | c.grab = id | |
| @@ -78,6 +78,9 @@ type Core struct { | |||||
| 78 | // clients holds the app ID and PID of mapped windows, for State. | 78 | // clients holds the app ID and PID of mapped windows, for State. | ||
| 79 | clients map[WindowID]ports.WindowMapped | 79 | clients map[WindowID]ports.WindowMapped | ||
| 80 | sentState ports.State | 80 | sentState ports.State | ||
| 81 | + | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |||
| 82 | + | popups map[WindowID]*popupState | |||
| 83 | + | popupOrder []WindowID | |||
| 81 | } | 84 | } | ||
| 82 | 85 | ||||
| 83 | func keyName(s string) string { | 86 | func keyName(s string) string { | ||
| @@ -260,7 +263,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||||
| 260 | return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1") | 263 | return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1") | ||
| 261 | } | 264 | } | ||
| 262 | // A placeholder screen holds windows until the first output arrives. | 265 | // A placeholder screen holds windows until the first output arrives. | ||
| 263 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 266 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, popups: map[WindowID]*popupState{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 264 | if err := c.apply(cfg); err != nil { | 267 | if err := c.apply(cfg); err != nil { | ||
| 265 | return nil, err | 268 | return nil, err | ||
| 266 | } | 269 | } | ||
| @@ -314,6 +317,10 @@ func (c *Core) keyboardFocus() WindowID { | |||||
| 314 | if layer != 0 { | 317 | if layer != 0 { | ||
| 315 | return layer | 318 | return layer | ||
| 316 | } | 319 | } | ||
| 320 | + | // A menu with a grab takes the keyboard until it closes. | |||
| 321 | + | if g := c.grabFocus(); g != 0 { | |||
| 322 | + | return g | |||
| 323 | + | } | |||
| 317 | id, _ := c.cur().mon.Focused() | 324 | id, _ := c.cur().mon.Focused() | ||
| 318 | return id | 325 | return id | ||
| 319 | } | 326 | } | ||
| @@ -337,6 +344,9 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||||
| 337 | } | 344 | } | ||
| 338 | 345 | ||||
| 339 | func (c *Core) publish(ctx context.Context) error { | 346 | func (c *Core) publish(ctx context.Context) error { | ||
| 347 | + | if err := c.closeHiddenPopups(ctx); err != nil { | |||
| 348 | + | return err | |||
| 349 | + | } | |||
| 340 | // Clients learn outputs and scales before the configures sized for them. | 350 | // Clients learn outputs and scales before the configures sized for them. | ||
| 341 | if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) { | 351 | if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) { | ||
| 342 | if err := c.command(ctx, v); err != nil { | 352 | if err := c.command(ctx, v); err != nil { | ||
| @@ -372,6 +382,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 372 | // Only the focused output shows the focused border. | 382 | // Only the focused output shows the focused border. | ||
| 373 | focused := p.Focused && i == c.focusScreen | 383 | focused := p.Focused && i == c.focusScreen | ||
| 374 | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | 384 | scene.Windows = append(scene.Windows, ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Borderless: p.Borderless}) | ||
| 385 | + | floating := p.Floating | |||
| 375 | if p.Hidden { | 386 | if p.Hidden { | ||
| 376 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | 387 | if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") { | ||
| 377 | old.Activated, old.Output = false, "" | 388 | old.Activated, old.Output = false, "" | ||
| @@ -383,7 +394,11 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 383 | continue | 394 | continue | ||
| 384 | } | 395 | } | ||
| 385 | r := c.clientRect(p) | 396 | r := c.clientRect(p) | ||
| 386 | - | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Output: sc.name()} | 397 | + | v := ports.ConfigureWindow{ID: p.ID, Width: r.W, Height: r.H, Fullscreen: p.Fullscreen, Activated: focused, Floating: floating && !p.Fullscreen, Output: sc.name()} |
| 398 | + | if v.Floating { | |||
| 399 | + | // A floating window picks its own size. | |||
| 400 | + | v.Width, v.Height = 0, 0 | |||
| 401 | + | } | |||
| 387 | if old, ok := c.sent[p.ID]; !ok || old != v { | 402 | if old, ok := c.sent[p.ID]; !ok || old != v { | ||
| 388 | if err := c.command(ctx, v); err != nil { | 403 | if err := c.command(ctx, v); err != nil { | ||
| 389 | return err | 404 | return err | ||
| @@ -391,6 +406,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 391 | c.sent[p.ID] = v | 406 | c.sent[p.ID] = v | ||
| 392 | } | 407 | } | ||
| 393 | } | 408 | } | ||
| 409 | + | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |||
| 394 | scenes = append(scenes, scene) | 410 | scenes = append(scenes, scene) | ||
| 395 | } | 411 | } | ||
| 396 | for id := range c.sent { | 412 | for id := range c.sent { | ||
| @@ -496,17 +512,26 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 496 | return 0, 0, 0 | 512 | return 0, 0, 0 | ||
| 497 | } | 513 | } | ||
| 498 | lx, ly := x-float64(o.X), y-float64(o.Y) | 514 | lx, ly := x-float64(o.X), y-float64(o.Y) | ||
| 515 | + | sc := c.screens[c.screenIndex(o.Info.Name)] | |||
| 516 | + | // Popups are above everything but fullscreen covers nothing of them. | |||
| 517 | + | if id, px, py := c.popupAt(sc, lx, ly); id != 0 { | |||
| 518 | + | return id, px, py | |||
| 519 | + | } | |||
| 499 | var id WindowID | 520 | var id WindowID | ||
| 500 | var sx, sy float64 | 521 | var sx, sy float64 | ||
| 501 | - | for _, p := range c.screens[c.screenIndex(o.Info.Name)].mon.Layout() { | 522 | + | full := false |
| 523 | + | for _, p := range sc.mon.Layout() { | |||
| 502 | r := c.clientRect(p) | 524 | r := c.clientRect(p) | ||
| 503 | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | 525 | if !p.Hidden && r.W > 0 && r.H > 0 && lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | ||
| 504 | - | if id == 0 || p.Fullscreen { | 526 | + | // Floating windows come last in the layout and are on top, |
| 505 | - | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) | 527 | + | // even of a fullscreen window; nothing else is. |
| 528 | + | if full && !p.Floating { | |||
| 529 | + | continue | |||
| 506 | } | 530 | } | ||
| 507 | - | if p.Fullscreen { | 531 | + | if id == 0 || p.Fullscreen || p.Floating { |
| 508 | - | break | 532 | + | id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y) |
| 509 | } | 533 | } | ||
| 534 | + | full = full || p.Fullscreen | |||
| 510 | } | 535 | } | ||
| 511 | } | 536 | } | ||
| 512 | return id, sx, sy | 537 | return id, sx, sy | ||
| @@ -541,17 +566,41 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 541 | c.setLayers(v.Layers) | 566 | c.setLayers(v.Layers) | ||
| 542 | case ports.WindowMapped: | 567 | case ports.WindowMapped: | ||
| 543 | c.clients[v.ID] = v | 568 | c.clients[v.ID] = v | ||
| 544 | - | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | 569 | + | if v.Floating { |
| 570 | + | if s, _ := c.screenOf(v.ID); s == nil { | |||
| 571 | + | c.cur().mon.AddFloating(v.ID, v.Width, v.Height) | |||
| 572 | + | } | |||
| 573 | + | } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |||
| 545 | if s, _ := c.screenOf(v.ID); s == nil { | 574 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 546 | c.cur().mon.AddWindow(v.ID) | 575 | c.cur().mon.AddWindow(v.ID) | ||
| 547 | } | 576 | } | ||
| 548 | } | 577 | } | ||
| 578 | + | case ports.WindowResized: | |||
| 579 | + | if _, w := c.screenOf(v.ID); w != nil { | |||
| 580 | + | w.ResizeFloating(v.ID, v.Width, v.Height) | |||
| 581 | + | } | |||
| 582 | + | case ports.PopupRequest: | |||
| 583 | + | if err := c.placePopup(ctx, v); err != nil { | |||
| 584 | + | return nil | |||
| 585 | + | } | |||
| 586 | + | case ports.PopupMapped: | |||
| 587 | + | if p := c.popups[v.ID]; p != nil { | |||
| 588 | + | p.mapped = true | |||
| 589 | + | } | |||
| 549 | case ports.WindowAppID: | 590 | case ports.WindowAppID: | ||
| 550 | if info, ok := c.clients[v.ID]; ok { | 591 | if info, ok := c.clients[v.ID]; ok { | ||
| 551 | info.AppID = v.AppID | 592 | info.AppID = v.AppID | ||
| 552 | c.clients[v.ID] = info | 593 | c.clients[v.ID] = info | ||
| 553 | } | 594 | } | ||
| 554 | case ports.WindowUnmapped: | 595 | case ports.WindowUnmapped: | ||
| 596 | + | if c.popups[v.ID] != nil { | |||
| 597 | + | if err := c.dropPopup(ctx, v.ID); err != nil { | |||
| 598 | + | return nil | |||
| 599 | + | } | |||
| 600 | + | } | |||
| 601 | + | if err := c.closePopupsOf(ctx, v.ID); err != nil { | |||
| 602 | + | return nil | |||
| 603 | + | } | |||
| 555 | delete(c.clients, v.ID) | 604 | delete(c.clients, v.ID) | ||
| 556 | if s, _ := c.screenOf(v.ID); s != nil { | 605 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 557 | s.mon.RemoveWindow(v.ID) | 606 | s.mon.RemoveWindow(v.ID) | ||
| @@ -655,6 +704,12 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 655 | if c.grab != 0 { | 704 | if c.grab != 0 { | ||
| 656 | id = c.grab | 705 | id = c.grab | ||
| 657 | } | 706 | } | ||
| 707 | + | if v.Pressed && len(c.buttons) == 0 { | |||
| 708 | + | // A click outside an open menu closes it. | |||
| 709 | + | if err := c.dismissGrabs(ctx, id); err != nil { | |||
| 710 | + | return nil | |||
| 711 | + | } | |||
| 712 | + | } | |||
| 658 | if v.Pressed { | 713 | if v.Pressed { | ||
| 659 | if len(c.buttons) == 0 { | 714 | if len(c.buttons) == 0 { | ||
| 660 | c.grab = id | 715 | c.grab = id | ||
M internal/core/core_test.go +81 -0
| @@ -641,3 +641,84 @@ 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 | + | } | |
| 692 | + | ||
| 693 | + | // A dialog over a fullscreen window gets the pointer. | |
| 694 | + | func TestFloatOverFullscreenHit(t *testing.T) { | |
| 695 | + | cfg := config.Defaults() | |
| 696 | + | cfg.Border.Width = 0 | |
| 697 | + | client := make(chan ports.ClientEvent, 8) | |
| 698 | + | input := make(chan ports.InputEvent, 8) | |
| 699 | + | output := make(chan ports.OutputEvent, 8) | |
| 700 | + | commands := make(chan ports.ClientCommand, 64) | |
| 701 | + | scenes := make(chan []ports.Scene, 1) | |
| 702 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 703 | + | if err != nil { | |
| 704 | + | t.Fatal(err) | |
| 705 | + | } | |
| 706 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 707 | + | defer cancel() | |
| 708 | + | go c.Run(ctx) | |
| 709 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 710 | + | scene(t, scenes) | |
| 711 | + | client <- ports.WindowMapped{ID: 1} | |
| 712 | + | scene(t, scenes) | |
| 713 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |
| 714 | + | scene(t, scenes) | |
| 715 | + | client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10} | |
| 716 | + | scene(t, scenes) | |
| 717 | + | for len(commands) > 0 { | |
| 718 | + | <-commands | |
| 719 | + | } | |
| 720 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |
| 721 | + | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 { | |
| 722 | + | t.Fatalf("pointer went to %v, want the dialog", v) | |
| 723 | + | } | |
| 724 | + | } | |
| @@ -641,3 +641,84 @@ 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 | + | } | |||
| 692 | + | ||||
| 693 | + | // A dialog over a fullscreen window gets the pointer. | |||
| 694 | + | func TestFloatOverFullscreenHit(t *testing.T) { | |||
| 695 | + | cfg := config.Defaults() | |||
| 696 | + | cfg.Border.Width = 0 | |||
| 697 | + | client := make(chan ports.ClientEvent, 8) | |||
| 698 | + | input := make(chan ports.InputEvent, 8) | |||
| 699 | + | output := make(chan ports.OutputEvent, 8) | |||
| 700 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 701 | + | scenes := make(chan []ports.Scene, 1) | |||
| 702 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 703 | + | if err != nil { | |||
| 704 | + | t.Fatal(err) | |||
| 705 | + | } | |||
| 706 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 707 | + | defer cancel() | |||
| 708 | + | go c.Run(ctx) | |||
| 709 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 710 | + | scene(t, scenes) | |||
| 711 | + | client <- ports.WindowMapped{ID: 1} | |||
| 712 | + | scene(t, scenes) | |||
| 713 | + | client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true} | |||
| 714 | + | scene(t, scenes) | |||
| 715 | + | client <- ports.WindowMapped{ID: 2, Floating: true, Width: 20, Height: 10} | |||
| 716 | + | scene(t, scenes) | |||
| 717 | + | for len(commands) > 0 { | |||
| 718 | + | <-commands | |||
| 719 | + | } | |||
| 720 | + | input <- ports.PointerMotion{X: 50, Y: 40} | |||
| 721 | + | if v, ok := command(t, commands).(ports.PointerFocus); !ok || v.ID != 2 { | |||
| 722 | + | t.Fatalf("pointer went to %v, want the dialog", v) | |||
| 723 | + | } | |||
| 724 | + | } | |||
M internal/core/layout.go +120 -5
| @@ -91,6 +91,8 @@ type Placement struct { | |||
| 91 | 91 | ID WindowID | |
| 92 | 92 | Rect Rect | |
| 93 | 93 | Fullscreen, Focused, Hidden bool | |
| 94 | + | // Floating windows sit over the columns at their own size. | |
| 95 | + | Floating bool | |
| 94 | 96 | // Borderless is set when the column fills the usable width: it is the only | |
| 95 | 97 | // column on screen, so no border marks focus. | |
| 96 | 98 | Borderless bool | |
| @@ -119,8 +121,14 @@ type Workspace struct { | |||
| 119 | 121 | Usable Rect | |
| 120 | 122 | Gaps int | |
| 121 | 123 | MaxColumns int | |
| 124 | + | // border is drawn around floating windows, inside their rect. | |
| 125 | + | border int | |
| 122 | 126 | presets []Width | |
| 123 | 127 | fullscreen WindowID | |
| 128 | + | // Floats are floating windows, bottom to top; floatFocus is set while | |
| 129 | + | // the top one has the focus instead of the columns. | |
| 130 | + | Floats []Float | |
| 131 | + | floatFocus bool | |
| 124 | 132 | // home is the monitor (key or connector) the workspace belongs to; "" | |
| 125 | 133 | // means the one it is on. On another monitor it is a guest; homePos is | |
| 126 | 134 | // its position there, where it returns. | |
| @@ -130,10 +138,51 @@ type Workspace struct { | |||
| 130 | 138 | termAt time.Time | |
| 131 | 139 | } | |
| 132 | 140 | ||
| 133 | - | func (w *Workspace) empty() bool { return len(w.Columns) == 0 } | |
| 141 | + | // Float is a floating window and its client size, logical. | |
| 142 | + | type Float struct { | |
| 143 | + | ID WindowID | |
| 144 | + | W, H int | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 } | |
| 148 | + | ||
| 149 | + | // windows lists every window: columns first, then floating ones. | |
| 150 | + | func (w *Workspace) windows() []WindowID { | |
| 151 | + | var ids []WindowID | |
| 152 | + | for _, c := range w.Columns { | |
| 153 | + | ids = append(ids, c.Windows...) | |
| 154 | + | } | |
| 155 | + | for _, f := range w.Floats { | |
| 156 | + | ids = append(ids, f.ID) | |
| 157 | + | } | |
| 158 | + | return ids | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (w *Workspace) floatIndex(id WindowID) int { | |
| 162 | + | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // AddFloating shows a window over the columns and focuses it. | |
| 166 | + | func (w *Workspace) AddFloating(id WindowID, width, height int) { | |
| 167 | + | if id == 0 || w.has(id) { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | |
| 171 | + | w.floatFocus = true | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // ResizeFloating records the size a floating window draws. | |
| 175 | + | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |
| 176 | + | if i := w.floatIndex(id); i >= 0 { | |
| 177 | + | w.Floats[i].W, w.Floats[i].H = width, height | |
| 178 | + | } | |
| 179 | + | } | |
| 134 | 180 | ||
| 135 | 181 | // has reports whether the workspace holds the window. | |
| 136 | 182 | func (w *Workspace) has(id WindowID) bool { | |
| 183 | + | if w.floatIndex(id) >= 0 { | |
| 184 | + | return true | |
| 185 | + | } | |
| 137 | 186 | for _, c := range w.Columns { | |
| 138 | 187 | for _, v := range c.Windows { | |
| 139 | 188 | if v == id { | |
| @@ -145,6 +194,9 @@ func (w *Workspace) has(id WindowID) bool { | |||
| 145 | 194 | } | |
| 146 | 195 | ||
| 147 | 196 | func (w *Workspace) Focused() (WindowID, bool) { | |
| 197 | + | if w.floatFocus && len(w.Floats) > 0 { | |
| 198 | + | return w.Floats[len(w.Floats)-1].ID, true | |
| 199 | + | } | |
| 148 | 200 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | |
| 149 | 201 | return 0, false | |
| 150 | 202 | } | |
| @@ -223,6 +275,16 @@ func (w *Workspace) unslot(n int) { | |||
| 223 | 275 | } | |
| 224 | 276 | ||
| 225 | 277 | func (w *Workspace) RemoveWindow(id WindowID) { | |
| 278 | + | if i := w.floatIndex(id); i >= 0 { | |
| 279 | + | w.Floats = slices.Delete(w.Floats, i, i+1) | |
| 280 | + | if w.fullscreen == id { | |
| 281 | + | w.fullscreen = 0 | |
| 282 | + | } | |
| 283 | + | if len(w.Floats) == 0 { | |
| 284 | + | w.floatFocus = false | |
| 285 | + | } | |
| 286 | + | return | |
| 287 | + | } | |
| 226 | 288 | for i := range w.Columns { | |
| 227 | 289 | for j, v := range w.Columns[i].Windows { | |
| 228 | 290 | if v != id { | |
| @@ -256,8 +318,16 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 256 | 318 | } | |
| 257 | 319 | } | |
| 258 | 320 | ||
| 259 | - | // FocusID selects a window and scrolls its column into view. | |
| 321 | + | // FocusID selects a window and scrolls its column into view; a floating | |
| 322 | + | // window is raised. | |
| 260 | 323 | func (w *Workspace) FocusID(id WindowID) bool { | |
| 324 | + | if i := w.floatIndex(id); i >= 0 { | |
| 325 | + | f := w.Floats[i] | |
| 326 | + | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |
| 327 | + | w.floatFocus = true | |
| 328 | + | return true | |
| 329 | + | } | |
| 330 | + | w.floatFocus = false | |
| 261 | 331 | for i := range w.Columns { | |
| 262 | 332 | for j, v := range w.Columns[i].Windows { | |
| 263 | 333 | if v == id { | |
| @@ -271,6 +341,11 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 271 | 341 | return false | |
| 272 | 342 | } | |
| 273 | 343 | func (w *Workspace) FocusColumn(dir int) { | |
| 344 | + | if w.floatFocus { | |
| 345 | + | // The first move leaves the floating window for the columns. | |
| 346 | + | w.floatFocus = false | |
| 347 | + | return | |
| 348 | + | } | |
| 274 | 349 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| 275 | 350 | w.Focus += dir | |
| 276 | 351 | w.scroll() | |
| @@ -279,6 +354,10 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 279 | 354 | ||
| 280 | 355 | // FocusWindow moves focus inside the column; false means it was already at the edge. | |
| 281 | 356 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 357 | + | if w.floatFocus { | |
| 358 | + | w.floatFocus = false | |
| 359 | + | return true | |
| 360 | + | } | |
| 282 | 361 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | |
| 283 | 362 | return false | |
| 284 | 363 | } | |
| @@ -291,6 +370,9 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||
| 291 | 370 | return false | |
| 292 | 371 | } | |
| 293 | 372 | func (w *Workspace) MoveColumn(dir int) { | |
| 373 | + | if w.floatFocus { | |
| 374 | + | return | |
| 375 | + | } | |
| 294 | 376 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| 295 | 377 | i := w.Focus | |
| 296 | 378 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | |
| @@ -301,7 +383,7 @@ func (w *Workspace) MoveColumn(dir int) { | |||
| 301 | 383 | ||
| 302 | 384 | // takeColumn removes the focused column and returns it, as a normal column. | |
| 303 | 385 | func (w *Workspace) takeColumn() (Column, bool) { | |
| 304 | - | if len(w.Columns) == 0 { | |
| 386 | + | if len(w.Columns) == 0 || w.floatFocus { | |
| 305 | 387 | return Column{}, false | |
| 306 | 388 | } | |
| 307 | 389 | col := w.Columns[w.Focus] | |
| @@ -328,7 +410,7 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 328 | 410 | } | |
| 329 | 411 | ||
| 330 | 412 | func (w *Workspace) CycleWidth() { | |
| 331 | - | if len(w.Columns) == 0 || len(w.presets) == 0 { | |
| 413 | + | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | |
| 332 | 414 | return | |
| 333 | 415 | } | |
| 334 | 416 | // auto → presets in order → auto. | |
| @@ -362,6 +444,14 @@ func (w *Workspace) ToggleFullscreen() { | |||
| 362 | 444 | // SetFullscreen applies a client request. It never moves focus: client | |
| 363 | 445 | // requests are automatic events (ADR 011 golden rule). | |
| 364 | 446 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |
| 447 | + | if w.floatIndex(id) >= 0 { | |
| 448 | + | if on { | |
| 449 | + | w.fullscreen = id | |
| 450 | + | } else if w.fullscreen == id { | |
| 451 | + | w.fullscreen = 0 | |
| 452 | + | } | |
| 453 | + | return | |
| 454 | + | } | |
| 365 | 455 | for i := range w.Columns { | |
| 366 | 456 | for _, v := range w.Columns[i].Windows { | |
| 367 | 457 | if v == id { | |
| @@ -503,6 +593,8 @@ func (w *Workspace) Layout() []Placement { | |||
| 503 | 593 | var result []Placement | |
| 504 | 594 | gap := w.gap() | |
| 505 | 595 | cols := w.columnRects() | |
| 596 | + | focusedID, _ := w.Focused() | |
| 597 | + | floatFocused := w.floatIndex(focusedID) >= 0 | |
| 506 | 598 | for i, c := range w.Columns { | |
| 507 | 599 | col := cols[i] | |
| 508 | 600 | n := len(c.Windows) | |
| @@ -539,9 +631,32 @@ func (w *Workspace) Layout() []Placement { | |||
| 539 | 631 | if hidden { | |
| 540 | 632 | r = Rect{} | |
| 541 | 633 | } | |
| 542 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | |
| 634 | + | focused := !floatFocused && i == w.Focus && j == c.Focus | |
| 635 | + | 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)}) | |
| 543 | 636 | y += h + gap | |
| 544 | 637 | } | |
| 545 | 638 | } | |
| 639 | + | // Floating windows go last: they are drawn and hit on top. | |
| 640 | + | for _, f := range w.Floats { | |
| 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. | |
| 644 | + | if w.fullscreen == f.ID { | |
| 645 | + | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |
| 646 | + | } | |
| 647 | + | result = append(result, p) | |
| 648 | + | } | |
| 546 | 649 | return result | |
| 547 | 650 | } | |
| 651 | + | ||
| 652 | + | // floatRect centres a floating window in the usable area, its border | |
| 653 | + | // around its client size, clamped to the area. | |
| 654 | + | func (w *Workspace) floatRect(f Float) Rect { | |
| 655 | + | u := w.Usable | |
| 656 | + | fw, fh := f.W, f.H | |
| 657 | + | if fw <= 0 || fh <= 0 { | |
| 658 | + | fw, fh = u.W/2, u.H/2 | |
| 659 | + | } | |
| 660 | + | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | |
| 661 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |
| 662 | + | } | |
| @@ -91,6 +91,8 @@ type Placement struct { | |||||
| 91 | ID WindowID | 91 | ID WindowID | ||
| 92 | Rect Rect | 92 | Rect Rect | ||
| 93 | Fullscreen, Focused, Hidden bool | 93 | Fullscreen, Focused, Hidden bool | ||
| 94 | + | // Floating windows sit over the columns at their own size. | |||
| 95 | + | Floating bool | |||
| 94 | // Borderless is set when the column fills the usable width: it is the only | 96 | // Borderless is set when the column fills the usable width: it is the only | ||
| 95 | // column on screen, so no border marks focus. | 97 | // column on screen, so no border marks focus. | ||
| 96 | Borderless bool | 98 | Borderless bool | ||
| @@ -119,8 +121,14 @@ type Workspace struct { | |||||
| 119 | Usable Rect | 121 | Usable Rect | ||
| 120 | Gaps int | 122 | Gaps int | ||
| 121 | MaxColumns int | 123 | MaxColumns int | ||
| 124 | + | // border is drawn around floating windows, inside their rect. | |||
| 125 | + | border int | |||
| 122 | presets []Width | 126 | presets []Width | ||
| 123 | fullscreen WindowID | 127 | fullscreen WindowID | ||
| 128 | + | // Floats are floating windows, bottom to top; floatFocus is set while | |||
| 129 | + | // the top one has the focus instead of the columns. | |||
| 130 | + | Floats []Float | |||
| 131 | + | floatFocus bool | |||
| 124 | // home is the monitor (key or connector) the workspace belongs to; "" | 132 | // home is the monitor (key or connector) the workspace belongs to; "" | ||
| 125 | // means the one it is on. On another monitor it is a guest; homePos is | 133 | // means the one it is on. On another monitor it is a guest; homePos is | ||
| 126 | // its position there, where it returns. | 134 | // its position there, where it returns. | ||
| @@ -130,10 +138,51 @@ type Workspace struct { | |||||
| 130 | termAt time.Time | 138 | termAt time.Time | ||
| 131 | } | 139 | } | ||
| 132 | 140 | ||||
| 133 | - | func (w *Workspace) empty() bool { return len(w.Columns) == 0 } | 141 | + | // Float is a floating window and its client size, logical. |
| 142 | + | type Float struct { | |||
| 143 | + | ID WindowID | |||
| 144 | + | W, H int | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (w *Workspace) empty() bool { return len(w.Columns) == 0 && len(w.Floats) == 0 } | |||
| 148 | + | ||||
| 149 | + | // windows lists every window: columns first, then floating ones. | |||
| 150 | + | func (w *Workspace) windows() []WindowID { | |||
| 151 | + | var ids []WindowID | |||
| 152 | + | for _, c := range w.Columns { | |||
| 153 | + | ids = append(ids, c.Windows...) | |||
| 154 | + | } | |||
| 155 | + | for _, f := range w.Floats { | |||
| 156 | + | ids = append(ids, f.ID) | |||
| 157 | + | } | |||
| 158 | + | return ids | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (w *Workspace) floatIndex(id WindowID) int { | |||
| 162 | + | return slices.IndexFunc(w.Floats, func(f Float) bool { return f.ID == id }) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // AddFloating shows a window over the columns and focuses it. | |||
| 166 | + | func (w *Workspace) AddFloating(id WindowID, width, height int) { | |||
| 167 | + | if id == 0 || w.has(id) { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | w.Floats = append(w.Floats, Float{ID: id, W: width, H: height}) | |||
| 171 | + | w.floatFocus = true | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // ResizeFloating records the size a floating window draws. | |||
| 175 | + | func (w *Workspace) ResizeFloating(id WindowID, width, height int) { | |||
| 176 | + | if i := w.floatIndex(id); i >= 0 { | |||
| 177 | + | w.Floats[i].W, w.Floats[i].H = width, height | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 134 | 180 | ||||
| 135 | // has reports whether the workspace holds the window. | 181 | // has reports whether the workspace holds the window. | ||
| 136 | func (w *Workspace) has(id WindowID) bool { | 182 | func (w *Workspace) has(id WindowID) bool { | ||
| 183 | + | if w.floatIndex(id) >= 0 { | |||
| 184 | + | return true | |||
| 185 | + | } | |||
| 137 | for _, c := range w.Columns { | 186 | for _, c := range w.Columns { | ||
| 138 | for _, v := range c.Windows { | 187 | for _, v := range c.Windows { | ||
| 139 | if v == id { | 188 | if v == id { | ||
| @@ -145,6 +194,9 @@ func (w *Workspace) has(id WindowID) bool { | |||||
| 145 | } | 194 | } | ||
| 146 | 195 | ||||
| 147 | func (w *Workspace) Focused() (WindowID, bool) { | 196 | func (w *Workspace) Focused() (WindowID, bool) { | ||
| 197 | + | if w.floatFocus && len(w.Floats) > 0 { | |||
| 198 | + | return w.Floats[len(w.Floats)-1].ID, true | |||
| 199 | + | } | |||
| 148 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | 200 | if w.Focus < 0 || w.Focus >= len(w.Columns) { | ||
| 149 | return 0, false | 201 | return 0, false | ||
| 150 | } | 202 | } | ||
| @@ -223,6 +275,16 @@ func (w *Workspace) unslot(n int) { | |||||
| 223 | } | 275 | } | ||
| 224 | 276 | ||||
| 225 | func (w *Workspace) RemoveWindow(id WindowID) { | 277 | func (w *Workspace) RemoveWindow(id WindowID) { | ||
| 278 | + | if i := w.floatIndex(id); i >= 0 { | |||
| 279 | + | w.Floats = slices.Delete(w.Floats, i, i+1) | |||
| 280 | + | if w.fullscreen == id { | |||
| 281 | + | w.fullscreen = 0 | |||
| 282 | + | } | |||
| 283 | + | if len(w.Floats) == 0 { | |||
| 284 | + | w.floatFocus = false | |||
| 285 | + | } | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 226 | for i := range w.Columns { | 288 | for i := range w.Columns { | ||
| 227 | for j, v := range w.Columns[i].Windows { | 289 | for j, v := range w.Columns[i].Windows { | ||
| 228 | if v != id { | 290 | if v != id { | ||
| @@ -256,8 +318,16 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 256 | } | 318 | } | ||
| 257 | } | 319 | } | ||
| 258 | 320 | ||||
| 259 | - | // FocusID selects a window and scrolls its column into view. | 321 | + | // FocusID selects a window and scrolls its column into view; a floating |
| 322 | + | // window is raised. | |||
| 260 | func (w *Workspace) FocusID(id WindowID) bool { | 323 | func (w *Workspace) FocusID(id WindowID) bool { | ||
| 324 | + | if i := w.floatIndex(id); i >= 0 { | |||
| 325 | + | f := w.Floats[i] | |||
| 326 | + | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |||
| 327 | + | w.floatFocus = true | |||
| 328 | + | return true | |||
| 329 | + | } | |||
| 330 | + | w.floatFocus = false | |||
| 261 | for i := range w.Columns { | 331 | for i := range w.Columns { | ||
| 262 | for j, v := range w.Columns[i].Windows { | 332 | for j, v := range w.Columns[i].Windows { | ||
| 263 | if v == id { | 333 | if v == id { | ||
| @@ -271,6 +341,11 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||||
| 271 | return false | 341 | return false | ||
| 272 | } | 342 | } | ||
| 273 | func (w *Workspace) FocusColumn(dir int) { | 343 | func (w *Workspace) FocusColumn(dir int) { | ||
| 344 | + | if w.floatFocus { | |||
| 345 | + | // The first move leaves the floating window for the columns. | |||
| 346 | + | w.floatFocus = false | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 274 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 349 | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | ||
| 275 | w.Focus += dir | 350 | w.Focus += dir | ||
| 276 | w.scroll() | 351 | w.scroll() | ||
| @@ -279,6 +354,10 @@ func (w *Workspace) FocusColumn(dir int) { | |||||
| 279 | 354 | ||||
| 280 | // FocusWindow moves focus inside the column; false means it was already at the edge. | 355 | // FocusWindow moves focus inside the column; false means it was already at the edge. | ||
| 281 | func (w *Workspace) FocusWindow(dir int) bool { | 356 | func (w *Workspace) FocusWindow(dir int) bool { | ||
| 357 | + | if w.floatFocus { | |||
| 358 | + | w.floatFocus = false | |||
| 359 | + | return true | |||
| 360 | + | } | |||
| 282 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | 361 | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | ||
| 283 | return false | 362 | return false | ||
| 284 | } | 363 | } | ||
| @@ -291,6 +370,9 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||||
| 291 | return false | 370 | return false | ||
| 292 | } | 371 | } | ||
| 293 | func (w *Workspace) MoveColumn(dir int) { | 372 | func (w *Workspace) MoveColumn(dir int) { | ||
| 373 | + | if w.floatFocus { | |||
| 374 | + | return | |||
| 375 | + | } | |||
| 294 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 376 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | ||
| 295 | i := w.Focus | 377 | i := w.Focus | ||
| 296 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | 378 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | ||
| @@ -301,7 +383,7 @@ func (w *Workspace) MoveColumn(dir int) { | |||||
| 301 | 383 | ||||
| 302 | // takeColumn removes the focused column and returns it, as a normal column. | 384 | // takeColumn removes the focused column and returns it, as a normal column. | ||
| 303 | func (w *Workspace) takeColumn() (Column, bool) { | 385 | func (w *Workspace) takeColumn() (Column, bool) { | ||
| 304 | - | if len(w.Columns) == 0 { | 386 | + | if len(w.Columns) == 0 || w.floatFocus { |
| 305 | return Column{}, false | 387 | return Column{}, false | ||
| 306 | } | 388 | } | ||
| 307 | col := w.Columns[w.Focus] | 389 | col := w.Columns[w.Focus] | ||
| @@ -328,7 +410,7 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||||
| 328 | } | 410 | } | ||
| 329 | 411 | ||||
| 330 | func (w *Workspace) CycleWidth() { | 412 | func (w *Workspace) CycleWidth() { | ||
| 331 | - | if len(w.Columns) == 0 || len(w.presets) == 0 { | 413 | + | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { |
| 332 | return | 414 | return | ||
| 333 | } | 415 | } | ||
| 334 | // auto → presets in order → auto. | 416 | // auto → presets in order → auto. | ||
| @@ -362,6 +444,14 @@ func (w *Workspace) ToggleFullscreen() { | |||||
| 362 | // SetFullscreen applies a client request. It never moves focus: client | 444 | // SetFullscreen applies a client request. It never moves focus: client | ||
| 363 | // requests are automatic events (ADR 011 golden rule). | 445 | // requests are automatic events (ADR 011 golden rule). | ||
| 364 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | 446 | func (w *Workspace) SetFullscreen(id WindowID, on bool) { | ||
| 447 | + | if w.floatIndex(id) >= 0 { | |||
| 448 | + | if on { | |||
| 449 | + | w.fullscreen = id | |||
| 450 | + | } else if w.fullscreen == id { | |||
| 451 | + | w.fullscreen = 0 | |||
| 452 | + | } | |||
| 453 | + | return | |||
| 454 | + | } | |||
| 365 | for i := range w.Columns { | 455 | for i := range w.Columns { | ||
| 366 | for _, v := range w.Columns[i].Windows { | 456 | for _, v := range w.Columns[i].Windows { | ||
| 367 | if v == id { | 457 | if v == id { | ||
| @@ -503,6 +593,8 @@ func (w *Workspace) Layout() []Placement { | |||||
| 503 | var result []Placement | 593 | var result []Placement | ||
| 504 | gap := w.gap() | 594 | gap := w.gap() | ||
| 505 | cols := w.columnRects() | 595 | cols := w.columnRects() | ||
| 596 | + | focusedID, _ := w.Focused() | |||
| 597 | + | floatFocused := w.floatIndex(focusedID) >= 0 | |||
| 506 | for i, c := range w.Columns { | 598 | for i, c := range w.Columns { | ||
| 507 | col := cols[i] | 599 | col := cols[i] | ||
| 508 | n := len(c.Windows) | 600 | n := len(c.Windows) | ||
| @@ -539,9 +631,32 @@ func (w *Workspace) Layout() []Placement { | |||||
| 539 | if hidden { | 631 | if hidden { | ||
| 540 | r = Rect{} | 632 | r = Rect{} | ||
| 541 | } | 633 | } | ||
| 542 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | 634 | + | focused := !floatFocused && i == w.Focus && j == c.Focus |
| 635 | + | 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)}) | |||
| 543 | y += h + gap | 636 | y += h + gap | ||
| 544 | } | 637 | } | ||
| 545 | } | 638 | } | ||
| 639 | + | // Floating windows go last: they are drawn and hit on top. | |||
| 640 | + | for _, f := range w.Floats { | |||
| 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. | |||
| 644 | + | if w.fullscreen == f.ID { | |||
| 645 | + | p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true | |||
| 646 | + | } | |||
| 647 | + | result = append(result, p) | |||
| 648 | + | } | |||
| 546 | return result | 649 | return result | ||
| 547 | } | 650 | } | ||
| 651 | + | ||||
| 652 | + | // floatRect centres a floating window in the usable area, its border | |||
| 653 | + | // around its client size, clamped to the area. | |||
| 654 | + | func (w *Workspace) floatRect(f Float) Rect { | |||
| 655 | + | u := w.Usable | |||
| 656 | + | fw, fh := f.W, f.H | |||
| 657 | + | if fw <= 0 || fh <= 0 { | |||
| 658 | + | fw, fh = u.W/2, u.H/2 | |||
| 659 | + | } | |||
| 660 | + | fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H) | |||
| 661 | + | return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh} | |||
| 662 | + | } | |||
M internal/core/monitor.go +25 -7
| @@ -82,6 +82,7 @@ func (m *Monitor) take(w *Workspace) { | |||
| 82 | 82 | func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | |
| 83 | 83 | w.presets = append([]Width(nil), m.template.presets...) | |
| 84 | 84 | w.Gaps = m.template.Gaps | |
| 85 | + | w.border = m.template.border | |
| 85 | 86 | w.SetOutput(m.template.Output.W, m.template.Output.H) | |
| 86 | 87 | w.SetUsable(m.template.Usable) | |
| 87 | 88 | if hidden { | |
| @@ -116,7 +117,7 @@ func (m *Monitor) Current() *Workspace { | |||
| 116 | 117 | func (m *Monitor) newWorkspace() *Workspace { | |
| 117 | 118 | w := m.template | |
| 118 | 119 | w.presets = append([]Width(nil), m.template.presets...) | |
| 119 | - | w.Columns, w.home = nil, "" | |
| 120 | + | w.Columns, w.Floats, w.floatFocus, w.home = nil, nil, false, "" | |
| 120 | 121 | return &w | |
| 121 | 122 | } | |
| 122 | 123 | ||
| @@ -230,6 +231,15 @@ func (m *Monitor) AddWindow(id WindowID) { | |||
| 230 | 231 | m.normalize() | |
| 231 | 232 | } | |
| 232 | 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 | + | ||
| 233 | 243 | // RemoveWindow drops the window wherever it is; focus stays on the active workspace. | |
| 234 | 244 | func (m *Monitor) RemoveWindow(id WindowID) { | |
| 235 | 245 | if w, _ := m.find(id); w != nil { | |
| @@ -255,8 +265,14 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||
| 255 | 265 | if !ok || m.Workspaces[i] == cur { | |
| 256 | 266 | return | |
| 257 | 267 | } | |
| 258 | - | cur.RemoveWindow(id) | |
| 259 | - | m.Workspaces[i].AddWindow(id) | |
| 268 | + | if f := cur.floatIndex(id); f >= 0 { | |
| 269 | + | fl := cur.Floats[f] | |
| 270 | + | cur.RemoveWindow(id) | |
| 271 | + | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | |
| 272 | + | } else { | |
| 273 | + | cur.RemoveWindow(id) | |
| 274 | + | m.Workspaces[i].AddWindow(id) | |
| 275 | + | } | |
| 260 | 276 | m.normalize() | |
| 261 | 277 | } | |
| 262 | 278 | ||
| @@ -271,10 +287,8 @@ func (m *Monitor) Layout() []Placement { | |||
| 271 | 287 | result = append(result, w.Layout()...) | |
| 272 | 288 | continue | |
| 273 | 289 | } | |
| 274 | - | for _, c := range w.Columns { | |
| 275 | - | for _, id := range c.Windows { | |
| 276 | - | result = append(result, Placement{ID: id, Hidden: true}) | |
| 277 | - | } | |
| 290 | + | for _, id := range w.windows() { | |
| 291 | + | result = append(result, Placement{ID: id, Hidden: true}) | |
| 278 | 292 | } | |
| 279 | 293 | } | |
| 280 | 294 | return result | |
| @@ -296,6 +310,7 @@ func (m *Monitor) SetOutput(width, height int) { | |||
| 296 | 310 | } | |
| 297 | 311 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 298 | 312 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 313 | + | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |
| 299 | 314 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |
| 300 | 315 | ||
| 301 | 316 | // SetMaxColumns sets the default; named workspaces may override it. | |
| @@ -364,6 +379,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 364 | 379 | m.Workspaces[m.Active].AddWindow(id) | |
| 365 | 380 | } | |
| 366 | 381 | } | |
| 382 | + | for _, f := range w.Floats { | |
| 383 | + | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | |
| 384 | + | } | |
| 367 | 385 | } | |
| 368 | 386 | m.hidden = hidden | |
| 369 | 387 | m.named = append([]NamedWorkspace(nil), specs...) | |
| @@ -82,6 +82,7 @@ func (m *Monitor) take(w *Workspace) { | |||||
| 82 | func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | 82 | func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | ||
| 83 | w.presets = append([]Width(nil), m.template.presets...) | 83 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 84 | w.Gaps = m.template.Gaps | 84 | w.Gaps = m.template.Gaps | ||
| 85 | + | w.border = m.template.border | |||
| 85 | w.SetOutput(m.template.Output.W, m.template.Output.H) | 86 | w.SetOutput(m.template.Output.W, m.template.Output.H) | ||
| 86 | w.SetUsable(m.template.Usable) | 87 | w.SetUsable(m.template.Usable) | ||
| 87 | if hidden { | 88 | if hidden { | ||
| @@ -116,7 +117,7 @@ func (m *Monitor) Current() *Workspace { | |||||
| 116 | func (m *Monitor) newWorkspace() *Workspace { | 117 | func (m *Monitor) newWorkspace() *Workspace { | ||
| 117 | w := m.template | 118 | w := m.template | ||
| 118 | w.presets = append([]Width(nil), m.template.presets...) | 119 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 119 | - | w.Columns, w.home = nil, "" | 120 | + | w.Columns, w.Floats, w.floatFocus, w.home = nil, nil, false, "" |
| 120 | return &w | 121 | return &w | ||
| 121 | } | 122 | } | ||
| 122 | 123 | ||||
| @@ -230,6 +231,15 @@ func (m *Monitor) AddWindow(id WindowID) { | |||||
| 230 | m.normalize() | 231 | m.normalize() | ||
| 231 | } | 232 | } | ||
| 232 | 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 | + | ||||
| 233 | // 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. | ||
| 234 | func (m *Monitor) RemoveWindow(id WindowID) { | 244 | func (m *Monitor) RemoveWindow(id WindowID) { | ||
| 235 | if w, _ := m.find(id); w != nil { | 245 | if w, _ := m.find(id); w != nil { | ||
| @@ -255,8 +265,14 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||||
| 255 | if !ok || m.Workspaces[i] == cur { | 265 | if !ok || m.Workspaces[i] == cur { | ||
| 256 | return | 266 | return | ||
| 257 | } | 267 | } | ||
| 258 | - | cur.RemoveWindow(id) | 268 | + | if f := cur.floatIndex(id); f >= 0 { |
| 259 | - | m.Workspaces[i].AddWindow(id) | 269 | + | fl := cur.Floats[f] |
| 270 | + | cur.RemoveWindow(id) | |||
| 271 | + | m.Workspaces[i].AddFloating(id, fl.W, fl.H) | |||
| 272 | + | } else { | |||
| 273 | + | cur.RemoveWindow(id) | |||
| 274 | + | m.Workspaces[i].AddWindow(id) | |||
| 275 | + | } | |||
| 260 | m.normalize() | 276 | m.normalize() | ||
| 261 | } | 277 | } | ||
| 262 | 278 | ||||
| @@ -271,10 +287,8 @@ func (m *Monitor) Layout() []Placement { | |||||
| 271 | result = append(result, w.Layout()...) | 287 | result = append(result, w.Layout()...) | ||
| 272 | continue | 288 | continue | ||
| 273 | } | 289 | } | ||
| 274 | - | for _, c := range w.Columns { | 290 | + | for _, id := range w.windows() { |
| 275 | - | for _, id := range c.Windows { | 291 | + | result = append(result, Placement{ID: id, Hidden: true}) |
| 276 | - | result = append(result, Placement{ID: id, Hidden: true}) | |||
| 277 | - | } | |||
| 278 | } | 292 | } | ||
| 279 | } | 293 | } | ||
| 280 | return result | 294 | return result | ||
| @@ -296,6 +310,7 @@ func (m *Monitor) SetOutput(width, height int) { | |||||
| 296 | } | 310 | } | ||
| 297 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | 311 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | ||
| 298 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | 312 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | ||
| 313 | + | func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) } | |||
| 299 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | 314 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | ||
| 300 | 315 | ||||
| 301 | // SetMaxColumns sets the default; named workspaces may override it. | 316 | // SetMaxColumns sets the default; named workspaces may override it. | ||
| @@ -364,6 +379,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||||
| 364 | m.Workspaces[m.Active].AddWindow(id) | 379 | m.Workspaces[m.Active].AddWindow(id) | ||
| 365 | } | 380 | } | ||
| 366 | } | 381 | } | ||
| 382 | + | for _, f := range w.Floats { | |||
| 383 | + | m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H) | |||
| 384 | + | } | |||
| 367 | } | 385 | } | ||
| 368 | m.hidden = hidden | 386 | m.hidden = hidden | ||
| 369 | m.named = append([]NamedWorkspace(nil), specs...) | 387 | m.named = append([]NamedWorkspace(nil), specs...) | ||
A internal/core/popup.go +306 -0
| @@ -0,0 +1,306 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "slices" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // popupState is a placed xdg_popup. Rect is relative to the parent's | |
| 11 | + | // window geometry, as sent to the client. | |
| 12 | + | type popupState struct { | |
| 13 | + | id, parent WindowID | |
| 14 | + | rect Rect | |
| 15 | + | grab bool | |
| 16 | + | mapped bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // place computes a popup rectangle relative to its parent, kept inside | |
| 20 | + | // bounds (also parent-relative) as the positioner's adjustments allow. | |
| 21 | + | func place(p ports.Positioner, bounds Rect) Rect { | |
| 22 | + | r := initial(p, p.Anchor, p.Gravity) | |
| 23 | + | if bounds.W <= 0 || bounds.H <= 0 { | |
| 24 | + | return r | |
| 25 | + | } | |
| 26 | + | outX := func(r Rect) bool { return r.X < bounds.X || r.X+r.W > bounds.X+bounds.W } | |
| 27 | + | outY := func(r Rect) bool { return r.Y < bounds.Y || r.Y+r.H > bounds.Y+bounds.H } | |
| 28 | + | if outX(r) && p.Adjust&ports.AdjustFlipX != 0 { | |
| 29 | + | if f := initial(p, flipX(p.Anchor), flipX(p.Gravity)); !outX(f) { | |
| 30 | + | r.X = f.X | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | if outY(r) && p.Adjust&ports.AdjustFlipY != 0 { | |
| 34 | + | if f := initial(p, flipY(p.Anchor), flipY(p.Gravity)); !outY(f) { | |
| 35 | + | r.Y = f.Y | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | if outX(r) && p.Adjust&ports.AdjustSlideX != 0 { | |
| 39 | + | r.X = max(min(r.X, bounds.X+bounds.W-r.W), bounds.X) | |
| 40 | + | } | |
| 41 | + | if outY(r) && p.Adjust&ports.AdjustSlideY != 0 { | |
| 42 | + | r.Y = max(min(r.Y, bounds.Y+bounds.H-r.H), bounds.Y) | |
| 43 | + | } | |
| 44 | + | if outX(r) && p.Adjust&ports.AdjustResizeX != 0 { | |
| 45 | + | x0, x1 := max(r.X, bounds.X), min(r.X+r.W, bounds.X+bounds.W) | |
| 46 | + | if x1 > x0 { | |
| 47 | + | r.X, r.W = x0, x1-x0 | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | if outY(r) && p.Adjust&ports.AdjustResizeY != 0 { | |
| 51 | + | y0, y1 := max(r.Y, bounds.Y), min(r.Y+r.H, bounds.Y+bounds.H) | |
| 52 | + | if y1 > y0 { | |
| 53 | + | r.Y, r.H = y0, y1-y0 | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return r | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // initial is the unconstrained placement for an anchor and gravity. | |
| 60 | + | func initial(p ports.Positioner, anchor, gravity uint32) Rect { | |
| 61 | + | a := p.AnchorRect | |
| 62 | + | x, y := a.X+a.W/2, a.Y+a.H/2 | |
| 63 | + | switch anchor { | |
| 64 | + | case ports.EdgeLeft, ports.EdgeTopLeft, ports.EdgeBottomLeft: | |
| 65 | + | x = a.X | |
| 66 | + | case ports.EdgeRight, ports.EdgeTopRight, ports.EdgeBottomRight: | |
| 67 | + | x = a.X + a.W | |
| 68 | + | } | |
| 69 | + | switch anchor { | |
| 70 | + | case ports.EdgeTop, ports.EdgeTopLeft, ports.EdgeTopRight: | |
| 71 | + | y = a.Y | |
| 72 | + | case ports.EdgeBottom, ports.EdgeBottomLeft, ports.EdgeBottomRight: | |
| 73 | + | y = a.Y + a.H | |
| 74 | + | } | |
| 75 | + | x, y = x+p.OffsetX, y+p.OffsetY | |
| 76 | + | r := Rect{X: x - p.Width/2, Y: y - p.Height/2, W: p.Width, H: p.Height} | |
| 77 | + | switch gravity { | |
| 78 | + | case ports.EdgeLeft, ports.EdgeTopLeft, ports.EdgeBottomLeft: | |
| 79 | + | r.X = x - p.Width | |
| 80 | + | case ports.EdgeRight, ports.EdgeTopRight, ports.EdgeBottomRight: | |
| 81 | + | r.X = x | |
| 82 | + | } | |
| 83 | + | switch gravity { | |
| 84 | + | case ports.EdgeTop, ports.EdgeTopLeft, ports.EdgeTopRight: | |
| 85 | + | r.Y = y - p.Height | |
| 86 | + | case ports.EdgeBottom, ports.EdgeBottomLeft, ports.EdgeBottomRight: | |
| 87 | + | r.Y = y | |
| 88 | + | } | |
| 89 | + | return r | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func flipX(e uint32) uint32 { | |
| 93 | + | switch e { | |
| 94 | + | case ports.EdgeLeft: | |
| 95 | + | return ports.EdgeRight | |
| 96 | + | case ports.EdgeRight: | |
| 97 | + | return ports.EdgeLeft | |
| 98 | + | case ports.EdgeTopLeft: | |
| 99 | + | return ports.EdgeTopRight | |
| 100 | + | case ports.EdgeTopRight: | |
| 101 | + | return ports.EdgeTopLeft | |
| 102 | + | case ports.EdgeBottomLeft: | |
| 103 | + | return ports.EdgeBottomRight | |
| 104 | + | case ports.EdgeBottomRight: | |
| 105 | + | return ports.EdgeBottomLeft | |
| 106 | + | } | |
| 107 | + | return e | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func flipY(e uint32) uint32 { | |
| 111 | + | switch e { | |
| 112 | + | case ports.EdgeTop: | |
| 113 | + | return ports.EdgeBottom | |
| 114 | + | case ports.EdgeBottom: | |
| 115 | + | return ports.EdgeTop | |
| 116 | + | case ports.EdgeTopLeft: | |
| 117 | + | return ports.EdgeBottomLeft | |
| 118 | + | case ports.EdgeBottomLeft: | |
| 119 | + | return ports.EdgeTopLeft | |
| 120 | + | case ports.EdgeTopRight: | |
| 121 | + | return ports.EdgeBottomRight | |
| 122 | + | case ports.EdgeBottomRight: | |
| 123 | + | return ports.EdgeTopRight | |
| 124 | + | } | |
| 125 | + | return e | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // windowRect is where a window or popup is on its screen: its client | |
| 129 | + | // area, output-local and logical. ok is false when it is not on screen. | |
| 130 | + | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |
| 131 | + | if p := c.popups[id]; p != nil { | |
| 132 | + | sc, pr, ok := c.windowRect(p.parent) | |
| 133 | + | if !ok { | |
| 134 | + | return nil, Rect{}, false | |
| 135 | + | } | |
| 136 | + | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |
| 137 | + | } | |
| 138 | + | for _, sc := range c.screens { | |
| 139 | + | for _, pl := range sc.mon.Layout() { | |
| 140 | + | if pl.ID == id { | |
| 141 | + | if pl.Hidden { | |
| 142 | + | return nil, Rect{}, false | |
| 143 | + | } | |
| 144 | + | return sc, c.clientRect(pl), true | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | return nil, Rect{}, false | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // placePopup places a new or repositioned popup and configures it; a popup | |
| 152 | + | // whose parent is not on screen is dismissed. | |
| 153 | + | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |
| 154 | + | sc, pr, ok := c.windowRect(v.Parent) | |
| 155 | + | if !ok { | |
| 156 | + | if c.popups[v.ID] != nil { | |
| 157 | + | return c.closePopup(ctx, v.ID) | |
| 158 | + | } | |
| 159 | + | return c.command(ctx, ports.ClosePopup{ID: v.ID}) | |
| 160 | + | } | |
| 161 | + | o := sc.mon.Output() | |
| 162 | + | bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H} | |
| 163 | + | p := c.popups[v.ID] | |
| 164 | + | if p == nil { | |
| 165 | + | p = &popupState{id: v.ID, parent: v.Parent} | |
| 166 | + | c.popups[v.ID] = p | |
| 167 | + | c.popupOrder = append(c.popupOrder, v.ID) | |
| 168 | + | } | |
| 169 | + | p.grab = v.Grab | |
| 170 | + | p.rect = place(v.Positioner, bounds) | |
| 171 | + | return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token}) | |
| 172 | + | } | |
| 173 | + | ||
| 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 | |
| 179 | + | } | |
| 180 | + | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | c.forgetPopup(id) | |
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 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)) { | |
| 191 | + | if p := c.popups[child]; p != nil && p.parent == id { | |
| 192 | + | if err := c.closePopup(ctx, child); err != nil { | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return nil | |
| 198 | + | } | |
| 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 | + | ||
| 220 | + | // closeHiddenPopups dismisses popups whose window left the screen. | |
| 221 | + | func (c *Core) closeHiddenPopups(ctx context.Context) error { | |
| 222 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |
| 223 | + | if c.popups[id] == nil { | |
| 224 | + | continue | |
| 225 | + | } | |
| 226 | + | if _, _, ok := c.windowRect(id); !ok { | |
| 227 | + | if err := c.closePopup(ctx, id); err != nil { | |
| 228 | + | return err | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // dismissGrabs closes the grabbing popups unless the click hit one of | |
| 236 | + | // them: a click outside a menu closes it. | |
| 237 | + | func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | |
| 238 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |
| 239 | + | p := c.popups[id] | |
| 240 | + | if p == nil || !p.grab { | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | // A click on a popup of the same chain keeps it; a click on | |
| 244 | + | // anything else, its parent window included, closes it. | |
| 245 | + | if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) { | |
| 246 | + | continue | |
| 247 | + | } | |
| 248 | + | if err := c.closePopup(ctx, id); err != nil { | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | return nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // isAncestor reports whether a is a popup ancestor of b. | |
| 256 | + | func (c *Core) isAncestor(a, b WindowID) bool { | |
| 257 | + | for p := c.popups[b]; p != nil; p = c.popups[p.parent] { | |
| 258 | + | if p.parent == a { | |
| 259 | + | return true | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // grabFocus is the topmost mapped grabbing popup, which takes the keyboard. | |
| 266 | + | func (c *Core) grabFocus() WindowID { | |
| 267 | + | for i := len(c.popupOrder) - 1; i >= 0; i-- { | |
| 268 | + | if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped { | |
| 269 | + | return p.id | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | return 0 | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // popupAt returns the topmost mapped popup under the output-local point. | |
| 276 | + | func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |
| 277 | + | for i := len(c.popupOrder) - 1; i >= 0; i-- { | |
| 278 | + | p := c.popups[c.popupOrder[i]] | |
| 279 | + | if p == nil || !p.mapped { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | s, r, ok := c.windowRect(p.id) | |
| 283 | + | if !ok || s != sc { | |
| 284 | + | continue | |
| 285 | + | } | |
| 286 | + | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |
| 287 | + | return p.id, lx - float64(r.X), ly - float64(r.Y) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | return 0, 0, 0 | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // scenePopups are the mapped popups on a screen, bottom to top. | |
| 294 | + | func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | |
| 295 | + | var out []ports.SceneWindow | |
| 296 | + | for _, id := range c.popupOrder { | |
| 297 | + | p := c.popups[id] | |
| 298 | + | if p == nil || !p.mapped { | |
| 299 | + | continue | |
| 300 | + | } | |
| 301 | + | if s, r, ok := c.windowRect(id); ok && s == sc { | |
| 302 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true}) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | return out | |
| 306 | + | } | |
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // popupState is a placed xdg_popup. Rect is relative to the parent's | |||
| 11 | + | // window geometry, as sent to the client. | |||
| 12 | + | type popupState struct { | |||
| 13 | + | id, parent WindowID | |||
| 14 | + | rect Rect | |||
| 15 | + | grab bool | |||
| 16 | + | mapped bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // place computes a popup rectangle relative to its parent, kept inside | |||
| 20 | + | // bounds (also parent-relative) as the positioner's adjustments allow. | |||
| 21 | + | func place(p ports.Positioner, bounds Rect) Rect { | |||
| 22 | + | r := initial(p, p.Anchor, p.Gravity) | |||
| 23 | + | if bounds.W <= 0 || bounds.H <= 0 { | |||
| 24 | + | return r | |||
| 25 | + | } | |||
| 26 | + | outX := func(r Rect) bool { return r.X < bounds.X || r.X+r.W > bounds.X+bounds.W } | |||
| 27 | + | outY := func(r Rect) bool { return r.Y < bounds.Y || r.Y+r.H > bounds.Y+bounds.H } | |||
| 28 | + | if outX(r) && p.Adjust&ports.AdjustFlipX != 0 { | |||
| 29 | + | if f := initial(p, flipX(p.Anchor), flipX(p.Gravity)); !outX(f) { | |||
| 30 | + | r.X = f.X | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | if outY(r) && p.Adjust&ports.AdjustFlipY != 0 { | |||
| 34 | + | if f := initial(p, flipY(p.Anchor), flipY(p.Gravity)); !outY(f) { | |||
| 35 | + | r.Y = f.Y | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | if outX(r) && p.Adjust&ports.AdjustSlideX != 0 { | |||
| 39 | + | r.X = max(min(r.X, bounds.X+bounds.W-r.W), bounds.X) | |||
| 40 | + | } | |||
| 41 | + | if outY(r) && p.Adjust&ports.AdjustSlideY != 0 { | |||
| 42 | + | r.Y = max(min(r.Y, bounds.Y+bounds.H-r.H), bounds.Y) | |||
| 43 | + | } | |||
| 44 | + | if outX(r) && p.Adjust&ports.AdjustResizeX != 0 { | |||
| 45 | + | x0, x1 := max(r.X, bounds.X), min(r.X+r.W, bounds.X+bounds.W) | |||
| 46 | + | if x1 > x0 { | |||
| 47 | + | r.X, r.W = x0, x1-x0 | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | if outY(r) && p.Adjust&ports.AdjustResizeY != 0 { | |||
| 51 | + | y0, y1 := max(r.Y, bounds.Y), min(r.Y+r.H, bounds.Y+bounds.H) | |||
| 52 | + | if y1 > y0 { | |||
| 53 | + | r.Y, r.H = y0, y1-y0 | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return r | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // initial is the unconstrained placement for an anchor and gravity. | |||
| 60 | + | func initial(p ports.Positioner, anchor, gravity uint32) Rect { | |||
| 61 | + | a := p.AnchorRect | |||
| 62 | + | x, y := a.X+a.W/2, a.Y+a.H/2 | |||
| 63 | + | switch anchor { | |||
| 64 | + | case ports.EdgeLeft, ports.EdgeTopLeft, ports.EdgeBottomLeft: | |||
| 65 | + | x = a.X | |||
| 66 | + | case ports.EdgeRight, ports.EdgeTopRight, ports.EdgeBottomRight: | |||
| 67 | + | x = a.X + a.W | |||
| 68 | + | } | |||
| 69 | + | switch anchor { | |||
| 70 | + | case ports.EdgeTop, ports.EdgeTopLeft, ports.EdgeTopRight: | |||
| 71 | + | y = a.Y | |||
| 72 | + | case ports.EdgeBottom, ports.EdgeBottomLeft, ports.EdgeBottomRight: | |||
| 73 | + | y = a.Y + a.H | |||
| 74 | + | } | |||
| 75 | + | x, y = x+p.OffsetX, y+p.OffsetY | |||
| 76 | + | r := Rect{X: x - p.Width/2, Y: y - p.Height/2, W: p.Width, H: p.Height} | |||
| 77 | + | switch gravity { | |||
| 78 | + | case ports.EdgeLeft, ports.EdgeTopLeft, ports.EdgeBottomLeft: | |||
| 79 | + | r.X = x - p.Width | |||
| 80 | + | case ports.EdgeRight, ports.EdgeTopRight, ports.EdgeBottomRight: | |||
| 81 | + | r.X = x | |||
| 82 | + | } | |||
| 83 | + | switch gravity { | |||
| 84 | + | case ports.EdgeTop, ports.EdgeTopLeft, ports.EdgeTopRight: | |||
| 85 | + | r.Y = y - p.Height | |||
| 86 | + | case ports.EdgeBottom, ports.EdgeBottomLeft, ports.EdgeBottomRight: | |||
| 87 | + | r.Y = y | |||
| 88 | + | } | |||
| 89 | + | return r | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func flipX(e uint32) uint32 { | |||
| 93 | + | switch e { | |||
| 94 | + | case ports.EdgeLeft: | |||
| 95 | + | return ports.EdgeRight | |||
| 96 | + | case ports.EdgeRight: | |||
| 97 | + | return ports.EdgeLeft | |||
| 98 | + | case ports.EdgeTopLeft: | |||
| 99 | + | return ports.EdgeTopRight | |||
| 100 | + | case ports.EdgeTopRight: | |||
| 101 | + | return ports.EdgeTopLeft | |||
| 102 | + | case ports.EdgeBottomLeft: | |||
| 103 | + | return ports.EdgeBottomRight | |||
| 104 | + | case ports.EdgeBottomRight: | |||
| 105 | + | return ports.EdgeBottomLeft | |||
| 106 | + | } | |||
| 107 | + | return e | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func flipY(e uint32) uint32 { | |||
| 111 | + | switch e { | |||
| 112 | + | case ports.EdgeTop: | |||
| 113 | + | return ports.EdgeBottom | |||
| 114 | + | case ports.EdgeBottom: | |||
| 115 | + | return ports.EdgeTop | |||
| 116 | + | case ports.EdgeTopLeft: | |||
| 117 | + | return ports.EdgeBottomLeft | |||
| 118 | + | case ports.EdgeBottomLeft: | |||
| 119 | + | return ports.EdgeTopLeft | |||
| 120 | + | case ports.EdgeTopRight: | |||
| 121 | + | return ports.EdgeBottomRight | |||
| 122 | + | case ports.EdgeBottomRight: | |||
| 123 | + | return ports.EdgeTopRight | |||
| 124 | + | } | |||
| 125 | + | return e | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // windowRect is where a window or popup is on its screen: its client | |||
| 129 | + | // area, output-local and logical. ok is false when it is not on screen. | |||
| 130 | + | func (c *Core) windowRect(id WindowID) (*screen, Rect, bool) { | |||
| 131 | + | if p := c.popups[id]; p != nil { | |||
| 132 | + | sc, pr, ok := c.windowRect(p.parent) | |||
| 133 | + | if !ok { | |||
| 134 | + | return nil, Rect{}, false | |||
| 135 | + | } | |||
| 136 | + | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |||
| 137 | + | } | |||
| 138 | + | for _, sc := range c.screens { | |||
| 139 | + | for _, pl := range sc.mon.Layout() { | |||
| 140 | + | if pl.ID == id { | |||
| 141 | + | if pl.Hidden { | |||
| 142 | + | return nil, Rect{}, false | |||
| 143 | + | } | |||
| 144 | + | return sc, c.clientRect(pl), true | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | return nil, Rect{}, false | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // placePopup places a new or repositioned popup and configures it; a popup | |||
| 152 | + | // whose parent is not on screen is dismissed. | |||
| 153 | + | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |||
| 154 | + | sc, pr, ok := c.windowRect(v.Parent) | |||
| 155 | + | if !ok { | |||
| 156 | + | if c.popups[v.ID] != nil { | |||
| 157 | + | return c.closePopup(ctx, v.ID) | |||
| 158 | + | } | |||
| 159 | + | return c.command(ctx, ports.ClosePopup{ID: v.ID}) | |||
| 160 | + | } | |||
| 161 | + | o := sc.mon.Output() | |||
| 162 | + | bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H} | |||
| 163 | + | p := c.popups[v.ID] | |||
| 164 | + | if p == nil { | |||
| 165 | + | p = &popupState{id: v.ID, parent: v.Parent} | |||
| 166 | + | c.popups[v.ID] = p | |||
| 167 | + | c.popupOrder = append(c.popupOrder, v.ID) | |||
| 168 | + | } | |||
| 169 | + | p.grab = v.Grab | |||
| 170 | + | p.rect = place(v.Positioner, bounds) | |||
| 171 | + | return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token}) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 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 | |||
| 179 | + | } | |||
| 180 | + | if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | c.forgetPopup(id) | |||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 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)) { | |||
| 191 | + | if p := c.popups[child]; p != nil && p.parent == id { | |||
| 192 | + | if err := c.closePopup(ctx, child); err != nil { | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 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 | + | ||||
| 220 | + | // closeHiddenPopups dismisses popups whose window left the screen. | |||
| 221 | + | func (c *Core) closeHiddenPopups(ctx context.Context) error { | |||
| 222 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |||
| 223 | + | if c.popups[id] == nil { | |||
| 224 | + | continue | |||
| 225 | + | } | |||
| 226 | + | if _, _, ok := c.windowRect(id); !ok { | |||
| 227 | + | if err := c.closePopup(ctx, id); err != nil { | |||
| 228 | + | return err | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // dismissGrabs closes the grabbing popups unless the click hit one of | |||
| 236 | + | // them: a click outside a menu closes it. | |||
| 237 | + | func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error { | |||
| 238 | + | for _, id := range slices.Backward(slices.Clone(c.popupOrder)) { | |||
| 239 | + | p := c.popups[id] | |||
| 240 | + | if p == nil || !p.grab { | |||
| 241 | + | continue | |||
| 242 | + | } | |||
| 243 | + | // A click on a popup of the same chain keeps it; a click on | |||
| 244 | + | // anything else, its parent window included, closes it. | |||
| 245 | + | if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) { | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 248 | + | if err := c.closePopup(ctx, id); err != nil { | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | return nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // isAncestor reports whether a is a popup ancestor of b. | |||
| 256 | + | func (c *Core) isAncestor(a, b WindowID) bool { | |||
| 257 | + | for p := c.popups[b]; p != nil; p = c.popups[p.parent] { | |||
| 258 | + | if p.parent == a { | |||
| 259 | + | return true | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | return false | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // grabFocus is the topmost mapped grabbing popup, which takes the keyboard. | |||
| 266 | + | func (c *Core) grabFocus() WindowID { | |||
| 267 | + | for i := len(c.popupOrder) - 1; i >= 0; i-- { | |||
| 268 | + | if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped { | |||
| 269 | + | return p.id | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | return 0 | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // popupAt returns the topmost mapped popup under the output-local point. | |||
| 276 | + | func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |||
| 277 | + | for i := len(c.popupOrder) - 1; i >= 0; i-- { | |||
| 278 | + | p := c.popups[c.popupOrder[i]] | |||
| 279 | + | if p == nil || !p.mapped { | |||
| 280 | + | continue | |||
| 281 | + | } | |||
| 282 | + | s, r, ok := c.windowRect(p.id) | |||
| 283 | + | if !ok || s != sc { | |||
| 284 | + | continue | |||
| 285 | + | } | |||
| 286 | + | if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) { | |||
| 287 | + | return p.id, lx - float64(r.X), ly - float64(r.Y) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | return 0, 0, 0 | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // scenePopups are the mapped popups on a screen, bottom to top. | |||
| 294 | + | func (c *Core) scenePopups(sc *screen) []ports.SceneWindow { | |||
| 295 | + | var out []ports.SceneWindow | |||
| 296 | + | for _, id := range c.popupOrder { | |||
| 297 | + | p := c.popups[id] | |||
| 298 | + | if p == nil || !p.mapped { | |||
| 299 | + | continue | |||
| 300 | + | } | |||
| 301 | + | if s, r, ok := c.windowRect(id); ok && s == sc { | |||
| 302 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true}) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | return out | |||
| 306 | + | } | |||
A internal/core/popup_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestPlacePopup(t *testing.T) { | |
| 10 | + | menu := ports.Positioner{ | |
| 11 | + | Width: 40, Height: 30, | |
| 12 | + | AnchorRect: Rect{X: 10, Y: 10, W: 20, H: 10}, | |
| 13 | + | Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight, | |
| 14 | + | } | |
| 15 | + | bounds := Rect{X: 0, Y: 0, W: 100, H: 100} | |
| 16 | + | for _, tc := range []struct { | |
| 17 | + | name string | |
| 18 | + | mod func(p *ports.Positioner) | |
| 19 | + | bounds Rect | |
| 20 | + | want Rect | |
| 21 | + | }{ | |
| 22 | + | {"fits", func(*ports.Positioner) {}, bounds, Rect{X: 30, Y: 20, W: 40, H: 30}}, | |
| 23 | + | {"no bounds", func(p *ports.Positioner) { p.OffsetX = 500 }, Rect{}, Rect{X: 530, Y: 20, W: 40, H: 30}}, | |
| 24 | + | {"centre", func(p *ports.Positioner) { p.Anchor, p.Gravity = 0, 0 }, bounds, Rect{X: 0, Y: 0, W: 40, H: 30}}, | |
| 25 | + | {"flip x", func(p *ports.Positioner) { p.OffsetX = 40; p.Adjust = ports.AdjustFlipX }, Rect{W: 90, H: 100}, Rect{X: 10, Y: 20, W: 40, H: 30}}, | |
| 26 | + | {"flip y", func(p *ports.Positioner) { p.AnchorRect.Y = 70; p.Adjust = ports.AdjustFlipY }, bounds, Rect{X: 30, Y: 40, W: 40, H: 30}}, | |
| 27 | + | {"slide", func(p *ports.Positioner) { p.AnchorRect.X = 80; p.Adjust = ports.AdjustSlideX }, bounds, Rect{X: 60, Y: 20, W: 40, H: 30}}, | |
| 28 | + | {"resize", func(p *ports.Positioner) { p.AnchorRect.X = 80; p.Adjust = ports.AdjustResizeX }, bounds, Rect{X: 100, Y: 20, W: 40, H: 30}}, | |
| 29 | + | {"resize y", func(p *ports.Positioner) { p.AnchorRect.Y = 70; p.Adjust = ports.AdjustResizeY }, bounds, Rect{X: 30, Y: 80, W: 40, H: 20}}, | |
| 30 | + | {"none", func(p *ports.Positioner) { p.AnchorRect.X = 80 }, bounds, Rect{X: 100, Y: 20, W: 40, H: 30}}, | |
| 31 | + | } { | |
| 32 | + | p := menu | |
| 33 | + | tc.mod(&p) | |
| 34 | + | if got := place(p, tc.bounds); got != tc.want { | |
| 35 | + | t.Errorf("%s: got %+v want %+v", tc.name, got, tc.want) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestFloatingWindows(t *testing.T) { | |
| 41 | + | w := workspace() | |
| 42 | + | w.AddWindow(1) | |
| 43 | + | w.AddFloating(2, 20, 10) | |
| 44 | + | if id, _ := w.Focused(); id != 2 { | |
| 45 | + | t.Fatalf("focus %d, want the floating window", id) | |
| 46 | + | } | |
| 47 | + | l := w.Layout() | |
| 48 | + | f := l[len(l)-1] | |
| 49 | + | u := w.Usable | |
| 50 | + | if f.ID != 2 || !f.Floating || !f.Focused || f.Rect.W != 20 || f.Rect.H != 10 || | |
| 51 | + | f.Rect.X != u.X+(u.W-20)/2 || f.Rect.Y != u.Y+(u.H-10)/2 { | |
| 52 | + | t.Fatalf("float placement %+v in %+v", f, u) | |
| 53 | + | } | |
| 54 | + | w.ResizeFloating(2, 500, 500) | |
| 55 | + | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | |
| 56 | + | t.Fatalf("clamped %+v", r) | |
| 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) | |
| 66 | + | w.RemoveWindow(2) | |
| 67 | + | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | |
| 68 | + | t.Fatalf("focus after close %d", id) | |
| 69 | + | } | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestPlacePopup(t *testing.T) { | |||
| 10 | + | menu := ports.Positioner{ | |||
| 11 | + | Width: 40, Height: 30, | |||
| 12 | + | AnchorRect: Rect{X: 10, Y: 10, W: 20, H: 10}, | |||
| 13 | + | Anchor: ports.EdgeBottomRight, Gravity: ports.EdgeBottomRight, | |||
| 14 | + | } | |||
| 15 | + | bounds := Rect{X: 0, Y: 0, W: 100, H: 100} | |||
| 16 | + | for _, tc := range []struct { | |||
| 17 | + | name string | |||
| 18 | + | mod func(p *ports.Positioner) | |||
| 19 | + | bounds Rect | |||
| 20 | + | want Rect | |||
| 21 | + | }{ | |||
| 22 | + | {"fits", func(*ports.Positioner) {}, bounds, Rect{X: 30, Y: 20, W: 40, H: 30}}, | |||
| 23 | + | {"no bounds", func(p *ports.Positioner) { p.OffsetX = 500 }, Rect{}, Rect{X: 530, Y: 20, W: 40, H: 30}}, | |||
| 24 | + | {"centre", func(p *ports.Positioner) { p.Anchor, p.Gravity = 0, 0 }, bounds, Rect{X: 0, Y: 0, W: 40, H: 30}}, | |||
| 25 | + | {"flip x", func(p *ports.Positioner) { p.OffsetX = 40; p.Adjust = ports.AdjustFlipX }, Rect{W: 90, H: 100}, Rect{X: 10, Y: 20, W: 40, H: 30}}, | |||
| 26 | + | {"flip y", func(p *ports.Positioner) { p.AnchorRect.Y = 70; p.Adjust = ports.AdjustFlipY }, bounds, Rect{X: 30, Y: 40, W: 40, H: 30}}, | |||
| 27 | + | {"slide", func(p *ports.Positioner) { p.AnchorRect.X = 80; p.Adjust = ports.AdjustSlideX }, bounds, Rect{X: 60, Y: 20, W: 40, H: 30}}, | |||
| 28 | + | {"resize", func(p *ports.Positioner) { p.AnchorRect.X = 80; p.Adjust = ports.AdjustResizeX }, bounds, Rect{X: 100, Y: 20, W: 40, H: 30}}, | |||
| 29 | + | {"resize y", func(p *ports.Positioner) { p.AnchorRect.Y = 70; p.Adjust = ports.AdjustResizeY }, bounds, Rect{X: 30, Y: 80, W: 40, H: 20}}, | |||
| 30 | + | {"none", func(p *ports.Positioner) { p.AnchorRect.X = 80 }, bounds, Rect{X: 100, Y: 20, W: 40, H: 30}}, | |||
| 31 | + | } { | |||
| 32 | + | p := menu | |||
| 33 | + | tc.mod(&p) | |||
| 34 | + | if got := place(p, tc.bounds); got != tc.want { | |||
| 35 | + | t.Errorf("%s: got %+v want %+v", tc.name, got, tc.want) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestFloatingWindows(t *testing.T) { | |||
| 41 | + | w := workspace() | |||
| 42 | + | w.AddWindow(1) | |||
| 43 | + | w.AddFloating(2, 20, 10) | |||
| 44 | + | if id, _ := w.Focused(); id != 2 { | |||
| 45 | + | t.Fatalf("focus %d, want the floating window", id) | |||
| 46 | + | } | |||
| 47 | + | l := w.Layout() | |||
| 48 | + | f := l[len(l)-1] | |||
| 49 | + | u := w.Usable | |||
| 50 | + | if f.ID != 2 || !f.Floating || !f.Focused || f.Rect.W != 20 || f.Rect.H != 10 || | |||
| 51 | + | f.Rect.X != u.X+(u.W-20)/2 || f.Rect.Y != u.Y+(u.H-10)/2 { | |||
| 52 | + | t.Fatalf("float placement %+v in %+v", f, u) | |||
| 53 | + | } | |||
| 54 | + | w.ResizeFloating(2, 500, 500) | |||
| 55 | + | if r := w.Layout()[1].Rect; r.W != u.W || r.H != u.H { | |||
| 56 | + | t.Fatalf("clamped %+v", r) | |||
| 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) | |||
| 66 | + | w.RemoveWindow(2) | |||
| 67 | + | if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 { | |||
| 68 | + | t.Fatalf("focus after close %d", id) | |||
| 69 | + | } | |||
| 70 | + | } | |||
M internal/core/screens.go +1 -0
| @@ -303,6 +303,7 @@ func (c *Core) settings(m *Monitor) { | |||
| 303 | 303 | m.SetMaxColumns(c.cfg.Layout.MaxColumns) | |
| 304 | 304 | m.SetPresets(c.presets) | |
| 305 | 305 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 306 | + | m.SetBorder(c.cfg.Border.Width) | |
| 306 | 307 | } | |
| 307 | 308 | ||
| 308 | 309 | // neighbor returns the screen index left (-1) or right (+1) of the focused | |
| @@ -303,6 +303,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 303 | m.SetMaxColumns(c.cfg.Layout.MaxColumns) | 303 | m.SetMaxColumns(c.cfg.Layout.MaxColumns) | ||
| 304 | m.SetPresets(c.presets) | 304 | m.SetPresets(c.presets) | ||
| 305 | m.SetGaps(c.cfg.Layout.Gaps) | 305 | m.SetGaps(c.cfg.Layout.Gaps) | ||
| 306 | + | m.SetBorder(c.cfg.Border.Width) | |||
| 306 | } | 307 | } | ||
| 307 | 308 | ||||
| 308 | // neighbor returns the screen index left (-1) or right (+1) of the focused | 309 | // neighbor returns the screen index left (-1) or right (+1) of the focused | ||
M internal/core/state.go +3 -5
| @@ -38,11 +38,9 @@ func (c *Core) state() ports.State { | |||
| 38 | 38 | st.Outputs = append(st.Outputs, o) | |
| 39 | 39 | for _, w := range m.all() { | |
| 40 | 40 | n := indexOf(m.Workspaces, w) + 1 | |
| 41 | - | for _, col := range w.Columns { | |
| 42 | - | for _, id := range col.Windows { | |
| 43 | - | info := c.clients[id] | |
| 44 | - | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) | |
| 45 | - | } | |
| 41 | + | for _, id := range w.windows() { | |
| 42 | + | info := c.clients[id] | |
| 43 | + | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) | |
| 46 | 44 | } | |
| 47 | 45 | } | |
| 48 | 46 | } | |
| @@ -38,11 +38,9 @@ func (c *Core) state() ports.State { | |||||
| 38 | st.Outputs = append(st.Outputs, o) | 38 | st.Outputs = append(st.Outputs, o) | ||
| 39 | for _, w := range m.all() { | 39 | for _, w := range m.all() { | ||
| 40 | n := indexOf(m.Workspaces, w) + 1 | 40 | n := indexOf(m.Workspaces, w) + 1 | ||
| 41 | - | for _, col := range w.Columns { | 41 | + | for _, id := range w.windows() { |
| 42 | - | for _, id := range col.Windows { | 42 | + | info := c.clients[id] |
| 43 | - | info := c.clients[id] | 43 | + | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) |
| 44 | - | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) | |||
| 45 | - | } | |||
| 46 | } | 44 | } | ||
| 47 | } | 45 | } | ||
| 48 | } | 46 | } | ||
M internal/ports/messages.go +88 -1
| @@ -13,6 +13,11 @@ type WindowMapped struct { | |||
| 13 | 13 | Slot string | |
| 14 | 14 | // PID is the client process, 0 when unknown. | |
| 15 | 15 | PID int | |
| 16 | + | // Floating windows are dialogs (a parent) or fixed-size windows such | |
| 17 | + | // as splash screens: core shows them over the columns at their own | |
| 18 | + | // size, Width×Height logical (0 when unknown). | |
| 19 | + | Floating bool | |
| 20 | + | Width, Height int | |
| 16 | 21 | } | |
| 17 | 22 | ||
| 18 | 23 | // SlotEnv is the environment variable nefertty sets on processes it spawns | |
| @@ -42,6 +47,68 @@ type WindowAppID struct { | |||
| 42 | 47 | ||
| 43 | 48 | func (WindowAppID) clientEvent() {} | |
| 44 | 49 | ||
| 50 | + | // WindowResized carries wayland → core the new size of a floating window. | |
| 51 | + | type WindowResized struct { | |
| 52 | + | ID WindowID | |
| 53 | + | Width, Height int | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (WindowResized) clientEvent() {} | |
| 57 | + | ||
| 58 | + | // Popup anchors and gravities, as xdg_positioner values. | |
| 59 | + | const ( | |
| 60 | + | EdgeNone uint32 = iota | |
| 61 | + | EdgeTop | |
| 62 | + | EdgeBottom | |
| 63 | + | EdgeLeft | |
| 64 | + | EdgeRight | |
| 65 | + | EdgeTopLeft | |
| 66 | + | EdgeBottomLeft | |
| 67 | + | EdgeTopRight | |
| 68 | + | EdgeBottomRight | |
| 69 | + | ) | |
| 70 | + | ||
| 71 | + | // Popup constraint adjustments, as xdg_positioner values. | |
| 72 | + | const ( | |
| 73 | + | AdjustSlideX uint32 = 1 << iota | |
| 74 | + | AdjustSlideY | |
| 75 | + | AdjustFlipX | |
| 76 | + | AdjustFlipY | |
| 77 | + | AdjustResizeX | |
| 78 | + | AdjustResizeY | |
| 79 | + | ) | |
| 80 | + | ||
| 81 | + | // Positioner places a popup relative to its parent's window geometry: | |
| 82 | + | // the Anchor point of AnchorRect, moved by Offset, with the popup extending | |
| 83 | + | // towards Gravity. Adjust says how core may move it to stay on screen. | |
| 84 | + | type Positioner struct { | |
| 85 | + | Width, Height int | |
| 86 | + | AnchorRect Rect | |
| 87 | + | Anchor, Gravity uint32 | |
| 88 | + | OffsetX, OffsetY int | |
| 89 | + | Adjust uint32 | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // PopupRequest carries wayland → core a popup to place (xdg_popup), on | |
| 93 | + | // its first commit and on reposition (Reposition, with the client's | |
| 94 | + | // Token). Parent is a window or popup. Grab popups take the keyboard and | |
| 95 | + | // close on a click elsewhere. | |
| 96 | + | type PopupRequest struct { | |
| 97 | + | ID, Parent WindowID | |
| 98 | + | Positioner Positioner | |
| 99 | + | Grab bool | |
| 100 | + | Reposition bool | |
| 101 | + | Token uint32 | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (PopupRequest) clientEvent() {} | |
| 105 | + | ||
| 106 | + | // PopupMapped carries wayland → core a placed popup that now has content. | |
| 107 | + | // It ends with WindowUnmapped. | |
| 108 | + | type PopupMapped struct{ ID WindowID } | |
| 109 | + | ||
| 110 | + | func (PopupMapped) clientEvent() {} | |
| 111 | + | ||
| 45 | 112 | // Layer is a wlr-layer-shell stacking layer. | |
| 46 | 113 | type Layer uint32 | |
| 47 | 114 | ||
| @@ -228,7 +295,9 @@ type ConfigureWindow struct { | |||
| 228 | 295 | ID WindowID | |
| 229 | 296 | Width, Height int | |
| 230 | 297 | Fullscreen, Activated bool | |
| 231 | - | Output string | |
| 298 | + | // Floating windows are not tiled: no tiled states. | |
| 299 | + | Floating bool | |
| 300 | + | Output string | |
| 232 | 301 | } | |
| 233 | 302 | ||
| 234 | 303 | func (ConfigureWindow) clientCommand() {} | |
| @@ -304,6 +373,22 @@ type FocusWindow struct{ ID WindowID } | |||
| 304 | 373 | ||
| 305 | 374 | func (FocusWindow) clientCommand() {} | |
| 306 | 375 | ||
| 376 | + | // ConfigurePopup carries core → wayland a popup's place, relative to its | |
| 377 | + | // parent's window geometry. Reposition answers a reposition request. | |
| 378 | + | type ConfigurePopup struct { | |
| 379 | + | ID WindowID | |
| 380 | + | Rect Rect | |
| 381 | + | Reposition bool | |
| 382 | + | Token uint32 | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (ConfigurePopup) clientCommand() {} | |
| 386 | + | ||
| 387 | + | // ClosePopup carries core → wayland a popup to dismiss (popup_done). | |
| 388 | + | type ClosePopup struct{ ID WindowID } | |
| 389 | + | ||
| 390 | + | func (ClosePopup) clientCommand() {} | |
| 391 | + | ||
| 307 | 392 | // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local. | |
| 308 | 393 | type PointerFocus struct { | |
| 309 | 394 | ID WindowID | |
| @@ -509,6 +594,8 @@ type SceneWindow struct { | |||
| 509 | 594 | Focused, Fullscreen, Hidden bool | |
| 510 | 595 | // Borderless windows fill the usable width alone; no border is drawn. | |
| 511 | 596 | Borderless bool | |
| 597 | + | // Popups are drawn from their content only: no border, no background. | |
| 598 | + | Popup bool | |
| 512 | 599 | } | |
| 513 | 600 | ||
| 514 | 601 | // State carries core → state publisher a snapshot of what is on screen, for | |
| @@ -13,6 +13,11 @@ type WindowMapped struct { | |||||
| 13 | Slot string | 13 | Slot string | ||
| 14 | // PID is the client process, 0 when unknown. | 14 | // PID is the client process, 0 when unknown. | ||
| 15 | PID int | 15 | PID int | ||
| 16 | + | // Floating windows are dialogs (a parent) or fixed-size windows such | |||
| 17 | + | // as splash screens: core shows them over the columns at their own | |||
| 18 | + | // size, Width×Height logical (0 when unknown). | |||
| 19 | + | Floating bool | |||
| 20 | + | Width, Height int | |||
| 16 | } | 21 | } | ||
| 17 | 22 | ||||
| 18 | // SlotEnv is the environment variable nefertty sets on processes it spawns | 23 | // SlotEnv is the environment variable nefertty sets on processes it spawns | ||
| @@ -42,6 +47,68 @@ type WindowAppID struct { | |||||
| 42 | 47 | ||||
| 43 | func (WindowAppID) clientEvent() {} | 48 | func (WindowAppID) clientEvent() {} | ||
| 44 | 49 | ||||
| 50 | + | // WindowResized carries wayland → core the new size of a floating window. | |||
| 51 | + | type WindowResized struct { | |||
| 52 | + | ID WindowID | |||
| 53 | + | Width, Height int | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (WindowResized) clientEvent() {} | |||
| 57 | + | ||||
| 58 | + | // Popup anchors and gravities, as xdg_positioner values. | |||
| 59 | + | const ( | |||
| 60 | + | EdgeNone uint32 = iota | |||
| 61 | + | EdgeTop | |||
| 62 | + | EdgeBottom | |||
| 63 | + | EdgeLeft | |||
| 64 | + | EdgeRight | |||
| 65 | + | EdgeTopLeft | |||
| 66 | + | EdgeBottomLeft | |||
| 67 | + | EdgeTopRight | |||
| 68 | + | EdgeBottomRight | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 71 | + | // Popup constraint adjustments, as xdg_positioner values. | |||
| 72 | + | const ( | |||
| 73 | + | AdjustSlideX uint32 = 1 << iota | |||
| 74 | + | AdjustSlideY | |||
| 75 | + | AdjustFlipX | |||
| 76 | + | AdjustFlipY | |||
| 77 | + | AdjustResizeX | |||
| 78 | + | AdjustResizeY | |||
| 79 | + | ) | |||
| 80 | + | ||||
| 81 | + | // Positioner places a popup relative to its parent's window geometry: | |||
| 82 | + | // the Anchor point of AnchorRect, moved by Offset, with the popup extending | |||
| 83 | + | // towards Gravity. Adjust says how core may move it to stay on screen. | |||
| 84 | + | type Positioner struct { | |||
| 85 | + | Width, Height int | |||
| 86 | + | AnchorRect Rect | |||
| 87 | + | Anchor, Gravity uint32 | |||
| 88 | + | OffsetX, OffsetY int | |||
| 89 | + | Adjust uint32 | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // PopupRequest carries wayland → core a popup to place (xdg_popup), on | |||
| 93 | + | // its first commit and on reposition (Reposition, with the client's | |||
| 94 | + | // Token). Parent is a window or popup. Grab popups take the keyboard and | |||
| 95 | + | // close on a click elsewhere. | |||
| 96 | + | type PopupRequest struct { | |||
| 97 | + | ID, Parent WindowID | |||
| 98 | + | Positioner Positioner | |||
| 99 | + | Grab bool | |||
| 100 | + | Reposition bool | |||
| 101 | + | Token uint32 | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (PopupRequest) clientEvent() {} | |||
| 105 | + | ||||
| 106 | + | // PopupMapped carries wayland → core a placed popup that now has content. | |||
| 107 | + | // It ends with WindowUnmapped. | |||
| 108 | + | type PopupMapped struct{ ID WindowID } | |||
| 109 | + | ||||
| 110 | + | func (PopupMapped) clientEvent() {} | |||
| 111 | + | ||||
| 45 | // Layer is a wlr-layer-shell stacking layer. | 112 | // Layer is a wlr-layer-shell stacking layer. | ||
| 46 | type Layer uint32 | 113 | type Layer uint32 | ||
| 47 | 114 | ||||
| @@ -228,7 +295,9 @@ type ConfigureWindow struct { | |||||
| 228 | ID WindowID | 295 | ID WindowID | ||
| 229 | Width, Height int | 296 | Width, Height int | ||
| 230 | Fullscreen, Activated bool | 297 | Fullscreen, Activated bool | ||
| 231 | - | Output string | 298 | + | // Floating windows are not tiled: no tiled states. |
| 299 | + | Floating bool | |||
| 300 | + | Output string | |||
| 232 | } | 301 | } | ||
| 233 | 302 | ||||
| 234 | func (ConfigureWindow) clientCommand() {} | 303 | func (ConfigureWindow) clientCommand() {} | ||
| @@ -304,6 +373,22 @@ type FocusWindow struct{ ID WindowID } | |||||
| 304 | 373 | ||||
| 305 | func (FocusWindow) clientCommand() {} | 374 | func (FocusWindow) clientCommand() {} | ||
| 306 | 375 | ||||
| 376 | + | // ConfigurePopup carries core → wayland a popup's place, relative to its | |||
| 377 | + | // parent's window geometry. Reposition answers a reposition request. | |||
| 378 | + | type ConfigurePopup struct { | |||
| 379 | + | ID WindowID | |||
| 380 | + | Rect Rect | |||
| 381 | + | Reposition bool | |||
| 382 | + | Token uint32 | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func (ConfigurePopup) clientCommand() {} | |||
| 386 | + | ||||
| 387 | + | // ClosePopup carries core → wayland a popup to dismiss (popup_done). | |||
| 388 | + | type ClosePopup struct{ ID WindowID } | |||
| 389 | + | ||||
| 390 | + | func (ClosePopup) clientCommand() {} | |||
| 391 | + | ||||
| 307 | // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local. | 392 | // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local. | ||
| 308 | type PointerFocus struct { | 393 | type PointerFocus struct { | ||
| 309 | ID WindowID | 394 | ID WindowID | ||
| @@ -509,6 +594,8 @@ type SceneWindow struct { | |||||
| 509 | Focused, Fullscreen, Hidden bool | 594 | Focused, Fullscreen, Hidden bool | ||
| 510 | // Borderless windows fill the usable width alone; no border is drawn. | 595 | // Borderless windows fill the usable width alone; no border is drawn. | ||
| 511 | Borderless bool | 596 | Borderless bool | ||
| 597 | + | // Popups are drawn from their content only: no border, no background. | |||
| 598 | + | Popup bool | |||
| 512 | } | 599 | } | ||
| 513 | 600 | ||||
| 514 | // State carries core → state publisher a snapshot of what is on screen, for | 601 | // State carries core → state publisher a snapshot of what is on screen, for | ||