detached 6bc6748

feat(shell): add xdg_popup support and floating dialogs

commit
6bc6748207b53e91388c78456581c31ac5c0a2a1
author
brice · · 2026-09-25 14:03 UTC
parent
0679a6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1094 -64
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
506506 continue
507507 }
508508 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+ }
509515 // Content sits inside the border; core sized the client to match.
510516 b := 0
511517 if !w.Fullscreen && !w.Borderless {
@@ -506,6 +506,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa
506 continue506 continue
507 }507 }
508 x, y := w.Rect.X, w.Rect.Y508 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 := 0516 b := 0
511 if !w.Fullscreen && !w.Borderless {517 if !w.Fullscreen && !w.Borderless {
A internal/adapters/wayland/popup.go +140 -0
@@ -0,0 +1,140 @@
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+ w.unmap()
67+ }
68+ }
69+ res.OnDestroy = func() {
70+ // Core forgets the popup, placed or mapped.
71+ if !w.mapped && pp.sent {
72+ s.emit(ports.WindowUnmapped{ID: w.id})
73+ }
74+ w.unmap()
75+ pp.done = true
76+ x.surface.current, x.surface.pending = nil, nil
77+ x.surface.attached = false
78+ delete(s.windows, w.id)
79+ x.window = nil
80+ x.surface.role = nil
81+ x.configured, x.acked, x.serials = false, false, nil
82+ x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{}
83+ }
84+}
85+
86+func (*popup) Destroy(*xdgshell.Popup) {}
87+
88+// Grab is only valid before the first commit; core then gives the popup the
89+// keyboard and closes it on a click outside its popup chain.
90+func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, _ uint32) {
91+ if p.sent || p.w.mapped {
92+ r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed")
93+ return
94+ }
95+ p.grab = true
96+}
97+
98+func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token uint32) {
99+ s := p.w.xdg.server
100+ next, ok := s.positionerOf(p.w.xdg.wm, pos)
101+ if !ok || p.done {
102+ return
103+ }
104+ p.pos = next
105+ if p.sent {
106+ s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token})
107+ }
108+}
109+
110+// configure sends core's placement: xdg_popup.configure then
111+// xdg_surface.configure.
112+func (p *popup) configure(c ports.ConfigurePopup) {
113+ if p.done || !p.resource.Resource.Alive() || !p.w.xdg.resource.Resource.Alive() {
114+ return
115+ }
116+ if c.Reposition && p.resource.Version() >= 3 {
117+ p.resource.SendRepositioned(c.Token)
118+ }
119+ p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))
120+ s := p.w.xdg.server
121+ s.serial++
122+ p.w.xdg.resource.SendConfigure(s.serial)
123+ p.w.xdg.serials = append(p.w.xdg.serials, s.serial)
124+ p.w.xdg.configured = true
125+ if surf := p.w.xdg.surface; surf != nil {
126+ surf.sendTreeScale()
127+ }
128+}
129+
130+// dismiss sends popup_done once; the client then destroys the popup.
131+func (p *popup) dismiss() {
132+ if p.done {
133+ return
134+ }
135+ p.done = true
136+ if p.resource.Resource.Alive() {
137+ p.resource.SendPopupDone()
138+ }
139+ p.w.unmap()
140+}
@@ -0,0 +1,140 @@
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+ w.unmap()
67+ }
68+ }
69+ res.OnDestroy = func() {
70+ // Core forgets the popup, placed or mapped.
71+ if !w.mapped && pp.sent {
72+ s.emit(ports.WindowUnmapped{ID: w.id})
73+ }
74+ w.unmap()
75+ pp.done = true
76+ x.surface.current, x.surface.pending = nil, nil
77+ x.surface.attached = false
78+ delete(s.windows, w.id)
79+ x.window = nil
80+ x.surface.role = nil
81+ x.configured, x.acked, x.serials = false, false, nil
82+ x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{}
83+ }
84+}
85+
86+func (*popup) Destroy(*xdgshell.Popup) {}
87+
88+// Grab is only valid before the first commit; core then gives the popup the
89+// keyboard and closes it on a click outside its popup chain.
90+func (p *popup) Grab(r *xdgshell.Popup, _ *wayland.Seat, _ uint32) {
91+ if p.sent || p.w.mapped {
92+ r.PostError(uint32(xdgshell.PopupErrorInvalidGrab), "grab after the popup was committed")
93+ return
94+ }
95+ p.grab = true
96+}
97+
98+func (p *popup) Reposition(r *xdgshell.Popup, pos *xdgshell.Positioner, token uint32) {
99+ s := p.w.xdg.server
100+ next, ok := s.positionerOf(p.w.xdg.wm, pos)
101+ if !ok || p.done {
102+ return
103+ }
104+ p.pos = next
105+ if p.sent {
106+ s.emit(ports.PopupRequest{ID: p.w.id, Parent: p.parent.id, Positioner: next, Grab: p.grab, Reposition: true, Token: token})
107+ }
108+}
109+
110+// configure sends core's placement: xdg_popup.configure then
111+// xdg_surface.configure.
112+func (p *popup) configure(c ports.ConfigurePopup) {
113+ if p.done || !p.resource.Resource.Alive() || !p.w.xdg.resource.Resource.Alive() {
114+ return
115+ }
116+ if c.Reposition && p.resource.Version() >= 3 {
117+ p.resource.SendRepositioned(c.Token)
118+ }
119+ p.resource.SendConfigure(int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H))
120+ s := p.w.xdg.server
121+ s.serial++
122+ p.w.xdg.resource.SendConfigure(s.serial)
123+ p.w.xdg.serials = append(p.w.xdg.serials, s.serial)
124+ p.w.xdg.configured = true
125+ if surf := p.w.xdg.surface; surf != nil {
126+ surf.sendTreeScale()
127+ }
128+}
129+
130+// dismiss sends popup_done once; the client then destroys the popup.
131+func (p *popup) dismiss() {
132+ if p.done {
133+ return
134+ }
135+ p.done = true
136+ if p.resource.Resource.Alive() {
137+ p.resource.SendPopupDone()
138+ }
139+ p.w.unmap()
140+}
M internal/adapters/wayland/protocol_test.go +142 -0
@@ -157,6 +157,8 @@ func TestPopupBlocksSecondRole(t *testing.T) {
157157 registerProtocol(t, c, surf)
158158 positioner := c.AllocateID()
159159 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))
160162 if err := c.Roundtrip(); err != nil {
161163 t.Fatal(err)
162164 }
@@ -647,3 +649,143 @@ func surfaceMapper(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientE
647649 return mapped(t, events, 2*time.Second), surf, xdg
648650 }
649651 }
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, xdg649 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 (
1818 func (s *Server) outputOfSurface(surf *surface) *output {
1919 // Subsurfaces are on their root's output.
2020 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+ }
2125 name := ""
2226 switch {
2327 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 +12 -3
@@ -98,6 +98,7 @@ type Server struct {
9898 relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
9999 constraints map[*surface]*constraint
100100 constraint *constraint // the active one
101+ positioners map[*server.Resource]*positioner
101102 }
102103
103104 func removeItem[T comparable](list []T, v T) []T {
@@ -131,7 +132,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
131132 d.Close()
132133 return nil, err
133134 }
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}
135+ 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}
135136 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
136137 if opts.Keymap != "" {
137138 fd, size, e := keymapFile(opts.Keymap)
@@ -435,9 +436,17 @@ func (s *Server) apply(cmd ports.ClientCommand) {
435436 s.sendModifiers(k)
436437 }
437438 }
439+ case ports.ConfigurePopup:
440+ if w := s.windows[c.ID]; w != nil && w.popup != nil {
441+ w.popup.configure(c)
442+ }
443+ case ports.ClosePopup:
444+ if w := s.windows[c.ID]; w != nil && w.popup != nil {
445+ w.popup.dismiss()
446+ }
438447 case ports.ConfigureWindow:
439448 w := s.windows[c.ID]
440- if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
449+ if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
441450 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
442451 return
443452 }
@@ -453,7 +462,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
453462 s.slotsPending = c.Pending
454463 case ports.CloseWindow:
455464 w := s.windows[c.ID]
456- if w == nil || !w.toplevel.Resource.Alive() {
465+ if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
457466 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
458467 return
459468 }
@@ -98,6 +98,7 @@ type Server struct {
98 relatives map[server.Client][]*relativepointer.ZwpRelativePointerV198 relatives map[server.Client][]*relativepointer.ZwpRelativePointerV1
99 constraints map[*surface]*constraint99 constraints map[*surface]*constraint
100 constraint *constraint // the active one100 constraint *constraint // the active one
101+ positioners map[*server.Resource]*positioner
101 }102 }
102 103
103 func removeItem[T comparable](list []T, v T) []T {104 func removeItem[T comparable](list []T, v T) []T {
@@ -131,7 +132,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
131 d.Close()132 d.Close()
132 return nil, err133 return nil, err
133 }134 }
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}135+ 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{}136 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
136 if opts.Keymap != "" {137 if opts.Keymap != "" {
137 fd, size, e := keymapFile(opts.Keymap)138 fd, size, e := keymapFile(opts.Keymap)
@@ -435,9 +436,17 @@ func (s *Server) apply(cmd ports.ClientCommand) {
435 s.sendModifiers(k)436 s.sendModifiers(k)
436 }437 }
437 }438 }
439+ case ports.ConfigurePopup:
440+ if w := s.windows[c.ID]; w != nil && w.popup != nil {
441+ w.popup.configure(c)
442+ }
443+ case ports.ClosePopup:
444+ if w := s.windows[c.ID]; w != nil && w.popup != nil {
445+ w.popup.dismiss()
446+ }
438 case ports.ConfigureWindow:447 case ports.ConfigureWindow:
439 w := s.windows[c.ID]448 w := s.windows[c.ID]
440- if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {449+ 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")450 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
442 return451 return
443 }452 }
@@ -453,7 +462,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
453 s.slotsPending = c.Pending462 s.slotsPending = c.Pending
454 case ports.CloseWindow:463 case ports.CloseWindow:
455 w := s.windows[c.ID]464 w := s.windows[c.ID]
456- if w == nil || !w.toplevel.Resource.Alive() {465+ if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
457 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")466 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
458 return467 return
459 }468 }
M internal/adapters/wayland/surface.go +3 -0
@@ -234,6 +234,9 @@ func (s *surface) Commit(*wayland.Surface) {
234234 s.xdg.geometry, geometry = s.xdg.pendingGeometry, true
235235 }
236236 s.commitConstraint(geometry)
237+ if s.xdg != nil && s.xdg.window != nil {
238+ s.xdg.window.afterCommit()
239+ }
237240 if fresh || moved || geometry || s.sub.parent != nil {
238241 s.redraw()
239242 }
@@ -234,6 +234,9 @@ func (s *surface) Commit(*wayland.Surface) {
234 s.xdg.geometry, geometry = s.xdg.pendingGeometry, true234 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 }
1616
1717 func (wm) Destroy(*xdgshell.WmBase) {}
1818 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+ }
2125 }
2226 func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) {
2327 if w == nil {
@@ -31,7 +35,7 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) {
3135 r.PostError(uint32(xdgshell.WmBaseErrorRole), "surface already has role")
3236 return
3337 }
34- x := &xdgSurface{server: m.server, surface: state}
38+ x := &xdgSurface{wm: r, server: m.server, surface: state}
3539 resource, err := xdgshell.NewSurface(r.Client(), r.Version(), id, x)
3640 if err == nil {
3741 x.resource = resource
@@ -42,27 +46,73 @@ func (m wm) GetXdgSurface(r *xdgshell.WmBase, id uint32, w *wayland.Surface) {
4246
4347 }
4448
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) {}
4694
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+}
57107
58108 type xdgSurface struct {
109+ wm *xdgshell.WmBase
59110 resource *xdgshell.Surface
60111 server *Server
61112 surface *surface
62113 configured bool
63114 acked bool
64115 window *window
65- popup *xdgshell.Popup
66116 serials []uint32
67117 // geometry is the committed window geometry: the visible window inside
68118 // client shadows. Empty means the whole surface.
@@ -70,17 +120,25 @@ type xdgSurface struct {
70120 }
71121
72122 func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
73- if x.window != nil || x.popup != nil {
123+ if x.window != nil {
74124 r.PostError(uint32(xdgshell.SurfaceErrorDefunctRoleObject), "role object still exists")
75125 }
76126 }
77127
128+// window is an xdg_toplevel, or an xdg_popup when popup is set.
78129 type window struct {
79130 id ports.WindowID
80131 toplevel *xdgshell.Toplevel
132+ popup *popup
81133 xdg *xdgSurface
82134 appID, title string
83135 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
84142 // last is the most recent core configure, resent when decoration mode changes.
85143 last ports.ConfigureWindow
86144 hasLast bool
@@ -96,7 +154,7 @@ func (w *window) sendConfigure() {
96154 if c.Activated {
97155 states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
98156 }
99- if !c.Fullscreen && w.toplevel.Version() >= 2 {
157+ if !c.Fullscreen && !c.Floating && w.toplevel.Version() >= 2 {
100158 // Tiled: the client must use exactly this size (no CSD shadows or rounding).
101159 for _, st := range []xdgshell.ToplevelState{xdgshell.ToplevelStateTiledLeft, xdgshell.ToplevelStateTiledRight, xdgshell.ToplevelStateTiledTop, xdgshell.ToplevelStateTiledBottom} {
102160 states = binary.LittleEndian.AppendUint32(states, uint32(st))
@@ -127,7 +185,7 @@ func (w *window) unmap() {
127185 w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id})
128186 }
129187 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
130- if x.window != nil || x.popup != nil {
188+ if x.window != nil {
131189 r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
132190 return
133191 }
@@ -154,8 +212,10 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
154212 } else if buffer && !w.mapped && x.acked {
155213 w.mapped = true
156214 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")
159219 } else if !buffer && w.mapped {
160220 w.unmap()
161221 }
@@ -173,16 +233,12 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
173233 x.geometry, x.pendingGeometry = ports.Rect{}, ports.Rect{}
174234 }
175235 }
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 {
178238 r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "role object already constructed")
179239 return
180240 }
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)
186242 }
187243
188244 // SetWindowGeometry sets the visible window inside the surface; it
@@ -212,9 +268,19 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
212268
213269 type top struct{ w *window }
214270
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 }
218284 func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
219285 if appID == t.w.appID {
220286 return
@@ -227,10 +293,22 @@ func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
227293 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
228294 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
229295 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) {}
234312 func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {
235313 if t.w.mapped {
236314 t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})
@@ -243,11 +321,34 @@ func (t top) UnsetFullscreen(*xdgshell.Toplevel) {
243321 }
244322 func (top) SetMinimized(*xdgshell.Toplevel) {}
245323
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+}
247343
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+}
251352
252353 // surfacePoint turns window coordinates (core's, from the geometry origin)
253354 // 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 return36 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 = resource41 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.Surface110 resource *xdgshell.Surface
60 server *Server111 server *Server
61 surface *surface112 surface *surface
62 configured bool113 configured bool
63 acked bool114 acked bool
64 window *window115 window *window
65- popup *xdgshell.Popup
66 serials []uint32116 serials []uint32
67 // geometry is the committed window geometry: the visible window inside117 // 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.WindowID130 id ports.WindowID
80 toplevel *xdgshell.Toplevel131 toplevel *xdgshell.Toplevel
132+ popup *popup
81 xdg *xdgSurface133 xdg *xdgSurface
82 appID, title string134 appID, title string
83 mapped bool135 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.ConfigureWindow143 last ports.ConfigureWindow
86 hasLast bool144 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 return190 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 = true213 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 return239 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; it244 // 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 return286 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 +57 -5
@@ -78,6 +78,9 @@ type Core struct {
7878 // clients holds the app ID and PID of mapped windows, for State.
7979 clients map[WindowID]ports.WindowMapped
8080 sentState ports.State
81+ // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
82+ popups map[WindowID]*popupState
83+ popupOrder []WindowID
8184 }
8285
8386 func keyName(s string) string {
@@ -260,7 +263,7 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
260263 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
261264 }
262265 // 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{}}
264267 if err := c.apply(cfg); err != nil {
265268 return nil, err
266269 }
@@ -314,6 +317,10 @@ func (c *Core) keyboardFocus() WindowID {
314317 if layer != 0 {
315318 return layer
316319 }
320+ // A menu with a grab takes the keyboard until it closes.
321+ if g := c.grabFocus(); g != 0 {
322+ return g
323+ }
317324 id, _ := c.cur().mon.Focused()
318325 return id
319326 }
@@ -337,6 +344,9 @@ func (c *Core) setLayers(all []ports.LayerSurface) {
337344 }
338345
339346 func (c *Core) publish(ctx context.Context) error {
347+ if err := c.closeHiddenPopups(ctx); err != nil {
348+ return err
349+ }
340350 // Clients learn outputs and scales before the configures sized for them.
341351 if v := (ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name()}); !sameOutputs(v, c.sentOutputs) {
342352 if err := c.command(ctx, v); err != nil {
@@ -372,6 +382,7 @@ func (c *Core) publish(ctx context.Context) error {
372382 // Only the focused output shows the focused border.
373383 focused := p.Focused && i == c.focusScreen
374384 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
375386 if p.Hidden {
376387 if old, ok := c.sent[p.ID]; ok && (old.Activated || old.Output != "") {
377388 old.Activated, old.Output = false, ""
@@ -383,7 +394,11 @@ func (c *Core) publish(ctx context.Context) error {
383394 continue
384395 }
385396 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+ }
387402 if old, ok := c.sent[p.ID]; !ok || old != v {
388403 if err := c.command(ctx, v); err != nil {
389404 return err
@@ -391,6 +406,7 @@ func (c *Core) publish(ctx context.Context) error {
391406 c.sent[p.ID] = v
392407 }
393408 }
409+ scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
394410 scenes = append(scenes, scene)
395411 }
396412 for id := range c.sent {
@@ -496,12 +512,18 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
496512 return 0, 0, 0
497513 }
498514 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+ }
499520 var id WindowID
500521 var sx, sy float64
501- for _, p := range c.screens[c.screenIndex(o.Info.Name)].mon.Layout() {
522+ for _, p := range sc.mon.Layout() {
502523 r := c.clientRect(p)
503524 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 {
525+ // Floating windows come last in the layout and are on top.
526+ if id == 0 || p.Fullscreen || p.Floating {
505527 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
506528 }
507529 if p.Fullscreen {
@@ -541,17 +563,41 @@ func (c *Core) Run(ctx context.Context) error {
541563 c.setLayers(v.Layers)
542564 case ports.WindowMapped:
543565 c.clients[v.ID] = v
544- if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
566+ if v.Floating {
567+ if s, _ := c.screenOf(v.ID); s == nil {
568+ c.cur().mon.Current().AddFloating(v.ID, v.Width, v.Height)
569+ }
570+ } else if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
545571 if s, _ := c.screenOf(v.ID); s == nil {
546572 c.cur().mon.AddWindow(v.ID)
547573 }
548574 }
575+ case ports.WindowResized:
576+ if _, w := c.screenOf(v.ID); w != nil {
577+ w.ResizeFloating(v.ID, v.Width, v.Height)
578+ }
579+ case ports.PopupRequest:
580+ if err := c.placePopup(ctx, v); err != nil {
581+ return nil
582+ }
583+ case ports.PopupMapped:
584+ if p := c.popups[v.ID]; p != nil {
585+ p.mapped = true
586+ }
549587 case ports.WindowAppID:
550588 if info, ok := c.clients[v.ID]; ok {
551589 info.AppID = v.AppID
552590 c.clients[v.ID] = info
553591 }
554592 case ports.WindowUnmapped:
593+ if c.popups[v.ID] != nil {
594+ if err := c.dropPopup(ctx, v.ID); err != nil {
595+ return nil
596+ }
597+ }
598+ if err := c.dropPopupsOf(ctx, v.ID); err != nil {
599+ return nil
600+ }
555601 delete(c.clients, v.ID)
556602 if s, _ := c.screenOf(v.ID); s != nil {
557603 s.mon.RemoveWindow(v.ID)
@@ -655,6 +701,12 @@ func (c *Core) Run(ctx context.Context) error {
655701 if c.grab != 0 {
656702 id = c.grab
657703 }
704+ if v.Pressed && len(c.buttons) == 0 {
705+ // A click outside an open menu closes it.
706+ if err := c.dismissGrabs(ctx, id); err != nil {
707+ return nil
708+ }
709+ }
658710 if v.Pressed {
659711 if len(c.buttons) == 0 {
660712 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.WindowMapped79 clients map[WindowID]ports.WindowMapped
80 sentState ports.State80 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, err268 return nil, err
266 }269 }
@@ -314,6 +317,10 @@ func (c *Core) keyboardFocus() WindowID {
314 if layer != 0 {317 if layer != 0 {
315 return layer318 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 id325 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.focusScreen383 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 continue394 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 err404 return err
@@ -391,6 +406,7 @@ func (c *Core) publish(ctx context.Context) error {
391 c.sent[p.ID] = v406 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,12 +512,18 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
496 return 0, 0, 0512 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 WindowID520 var id WindowID
500 var sx, sy float64521 var sx, sy float64
501- for _, p := range c.screens[c.screenIndex(o.Info.Name)].mon.Layout() {522+ for _, p := range sc.mon.Layout() {
502 r := c.clientRect(p)523 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) {524 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 {525+ // Floating windows come last in the layout and are on top.
526+ if id == 0 || p.Fullscreen || p.Floating {
505 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)527 id, sx, sy = p.ID, lx-float64(r.X), ly-float64(r.Y)
506 }528 }
507 if p.Fullscreen {529 if p.Fullscreen {
@@ -541,17 +563,41 @@ func (c *Core) Run(ctx context.Context) error {
541 c.setLayers(v.Layers)563 c.setLayers(v.Layers)
542 case ports.WindowMapped:564 case ports.WindowMapped:
543 c.clients[v.ID] = v565 c.clients[v.ID] = v
544- if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {566+ if v.Floating {
567+ if s, _ := c.screenOf(v.ID); s == nil {
568+ c.cur().mon.Current().AddFloating(v.ID, v.Width, v.Height)
569+ }
570+ } 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 {571 if s, _ := c.screenOf(v.ID); s == nil {
546 c.cur().mon.AddWindow(v.ID)572 c.cur().mon.AddWindow(v.ID)
547 }573 }
548 }574 }
575+ case ports.WindowResized:
576+ if _, w := c.screenOf(v.ID); w != nil {
577+ w.ResizeFloating(v.ID, v.Width, v.Height)
578+ }
579+ case ports.PopupRequest:
580+ if err := c.placePopup(ctx, v); err != nil {
581+ return nil
582+ }
583+ case ports.PopupMapped:
584+ if p := c.popups[v.ID]; p != nil {
585+ p.mapped = true
586+ }
549 case ports.WindowAppID:587 case ports.WindowAppID:
550 if info, ok := c.clients[v.ID]; ok {588 if info, ok := c.clients[v.ID]; ok {
551 info.AppID = v.AppID589 info.AppID = v.AppID
552 c.clients[v.ID] = info590 c.clients[v.ID] = info
553 }591 }
554 case ports.WindowUnmapped:592 case ports.WindowUnmapped:
593+ if c.popups[v.ID] != nil {
594+ if err := c.dropPopup(ctx, v.ID); err != nil {
595+ return nil
596+ }
597+ }
598+ if err := c.dropPopupsOf(ctx, v.ID); err != nil {
599+ return nil
600+ }
555 delete(c.clients, v.ID)601 delete(c.clients, v.ID)
556 if s, _ := c.screenOf(v.ID); s != nil {602 if s, _ := c.screenOf(v.ID); s != nil {
557 s.mon.RemoveWindow(v.ID)603 s.mon.RemoveWindow(v.ID)
@@ -655,6 +701,12 @@ func (c *Core) Run(ctx context.Context) error {
655 if c.grab != 0 {701 if c.grab != 0 {
656 id = c.grab702 id = c.grab
657 }703 }
704+ if v.Pressed && len(c.buttons) == 0 {
705+ // A click outside an open menu closes it.
706+ if err := c.dismissGrabs(ctx, id); err != nil {
707+ return nil
708+ }
709+ }
658 if v.Pressed {710 if v.Pressed {
659 if len(c.buttons) == 0 {711 if len(c.buttons) == 0 {
660 c.grab = id712 c.grab = id
M internal/core/layout.go +119 -5
@@ -91,6 +91,8 @@ type Placement struct {
9191 ID WindowID
9292 Rect Rect
9393 Fullscreen, Focused, Hidden bool
94+ // Floating windows sit over the columns at their own size.
95+ Floating bool
9496 // Borderless is set when the column fills the usable width: it is the only
9597 // column on screen, so no border marks focus.
9698 Borderless bool
@@ -119,8 +121,14 @@ type Workspace struct {
119121 Usable Rect
120122 Gaps int
121123 MaxColumns int
124+ // border is drawn around floating windows, inside their rect.
125+ border int
122126 presets []Width
123127 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
124132 // home is the monitor (key or connector) the workspace belongs to; ""
125133 // means the one it is on. On another monitor it is a guest; homePos is
126134 // its position there, where it returns.
@@ -130,10 +138,51 @@ type Workspace struct {
130138 termAt time.Time
131139 }
132140
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+}
134180
135181 // has reports whether the workspace holds the window.
136182 func (w *Workspace) has(id WindowID) bool {
183+ if w.floatIndex(id) >= 0 {
184+ return true
185+ }
137186 for _, c := range w.Columns {
138187 for _, v := range c.Windows {
139188 if v == id {
@@ -145,6 +194,9 @@ func (w *Workspace) has(id WindowID) bool {
145194 }
146195
147196 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+ }
148200 if w.Focus < 0 || w.Focus >= len(w.Columns) {
149201 return 0, false
150202 }
@@ -223,6 +275,16 @@ func (w *Workspace) unslot(n int) {
223275 }
224276
225277 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+ }
226288 for i := range w.Columns {
227289 for j, v := range w.Columns[i].Windows {
228290 if v != id {
@@ -256,8 +318,16 @@ func (w *Workspace) RemoveWindow(id WindowID) {
256318 }
257319 }
258320
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.
260323 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
261331 for i := range w.Columns {
262332 for j, v := range w.Columns[i].Windows {
263333 if v == id {
@@ -271,6 +341,11 @@ func (w *Workspace) FocusID(id WindowID) bool {
271341 return false
272342 }
273343 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+ }
274349 if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
275350 w.Focus += dir
276351 w.scroll()
@@ -279,6 +354,10 @@ func (w *Workspace) FocusColumn(dir int) {
279354
280355 // FocusWindow moves focus inside the column; false means it was already at the edge.
281356 func (w *Workspace) FocusWindow(dir int) bool {
357+ if w.floatFocus {
358+ w.floatFocus = false
359+ return true
360+ }
282361 if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
283362 return false
284363 }
@@ -291,6 +370,9 @@ func (w *Workspace) FocusWindow(dir int) bool {
291370 return false
292371 }
293372 func (w *Workspace) MoveColumn(dir int) {
373+ if w.floatFocus {
374+ return
375+ }
294376 if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) {
295377 i := w.Focus
296378 w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i]
@@ -301,7 +383,7 @@ func (w *Workspace) MoveColumn(dir int) {
301383
302384 // takeColumn removes the focused column and returns it, as a normal column.
303385 func (w *Workspace) takeColumn() (Column, bool) {
304- if len(w.Columns) == 0 {
386+ if len(w.Columns) == 0 || w.floatFocus {
305387 return Column{}, false
306388 }
307389 col := w.Columns[w.Focus]
@@ -328,7 +410,7 @@ func (w *Workspace) insertColumn(at int, col Column) {
328410 }
329411
330412 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 {
332414 return
333415 }
334416 // auto → presets in order → auto.
@@ -362,6 +444,14 @@ func (w *Workspace) ToggleFullscreen() {
362444 // SetFullscreen applies a client request. It never moves focus: client
363445 // requests are automatic events (ADR 011 golden rule).
364446 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+ }
365455 for i := range w.Columns {
366456 for _, v := range w.Columns[i].Windows {
367457 if v == id {
@@ -539,9 +629,33 @@ func (w *Workspace) Layout() []Placement {
539629 if hidden {
540630 r = Rect{}
541631 }
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)})
632+ focused := !w.floatFocus && i == w.Focus && j == c.Focus
633+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
543634 y += h + gap
544635 }
545636 }
637+ // Floating windows go last: they are drawn and hit on top.
638+ for i, f := range w.Floats {
639+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: w.floatFocus && i == len(w.Floats)-1}
640+ if w.fullscreen == f.ID {
641+ p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true
642+ } else if w.fullscreen != 0 {
643+ // A fullscreen window covers the floating ones.
644+ p.Rect, p.Hidden = Rect{}, true
645+ }
646+ result = append(result, p)
647+ }
546648 return result
547649 }
650+
651+// floatRect centres a floating window in the usable area, its border
652+// around its client size, clamped to the area.
653+func (w *Workspace) floatRect(f Float) Rect {
654+ u := w.Usable
655+ fw, fh := f.W, f.H
656+ if fw <= 0 || fh <= 0 {
657+ fw, fh = u.W/2, u.H/2
658+ }
659+ fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
660+ return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
661+}
@@ -91,6 +91,8 @@ type Placement struct {
91 ID WindowID91 ID WindowID
92 Rect Rect92 Rect Rect
93 Fullscreen, Focused, Hidden bool93 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 only96 // 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 bool98 Borderless bool
@@ -119,8 +121,14 @@ type Workspace struct {
119 Usable Rect121 Usable Rect
120 Gaps int122 Gaps int
121 MaxColumns int123 MaxColumns int
124+ // border is drawn around floating windows, inside their rect.
125+ border int
122 presets []Width126 presets []Width
123 fullscreen WindowID127 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 is133 // 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.Time138 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, false201 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 false341 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 += dir350 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 false362 return false
284 }363 }
@@ -291,6 +370,9 @@ func (w *Workspace) FocusWindow(dir int) bool {
291 return false370 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.Focus377 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{}, false387 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 return414 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: client444 // 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 {
@@ -539,9 +629,33 @@ func (w *Workspace) Layout() []Placement {
539 if hidden {629 if hidden {
540 r = Rect{}630 r = Rect{}
541 }631 }
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)})632+ focused := !w.floatFocus && i == w.Focus && j == c.Focus
633+ result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: focused, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap && (w.Overflow != OverflowFixed || len(w.Columns) == 1)})
543 y += h + gap634 y += h + gap
544 }635 }
545 }636 }
637+ // Floating windows go last: they are drawn and hit on top.
638+ for i, f := range w.Floats {
639+ p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Focused: w.floatFocus && i == len(w.Floats)-1}
640+ if w.fullscreen == f.ID {
641+ p.Rect, p.Fullscreen = Rect{W: w.Output.W, H: w.Output.H}, true
642+ } else if w.fullscreen != 0 {
643+ // A fullscreen window covers the floating ones.
644+ p.Rect, p.Hidden = Rect{}, true
645+ }
646+ result = append(result, p)
647+ }
546 return result648 return result
547 }649 }
650+
651+// floatRect centres a floating window in the usable area, its border
652+// around its client size, clamped to the area.
653+func (w *Workspace) floatRect(f Float) Rect {
654+ u := w.Usable
655+ fw, fh := f.W, f.H
656+ if fw <= 0 || fh <= 0 {
657+ fw, fh = u.W/2, u.H/2
658+ }
659+ fw, fh = min(fw+2*w.border, u.W), min(fh+2*w.border, u.H)
660+ return Rect{X: u.X + (u.W-fw)/2, Y: u.Y + (u.H-fh)/2, W: fw, H: fh}
661+}
M internal/core/monitor.go +16 -7
@@ -82,6 +82,7 @@ func (m *Monitor) take(w *Workspace) {
8282 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
8383 w.presets = append([]Width(nil), m.template.presets...)
8484 w.Gaps = m.template.Gaps
85+ w.border = m.template.border
8586 w.SetOutput(m.template.Output.W, m.template.Output.H)
8687 w.SetUsable(m.template.Usable)
8788 if hidden {
@@ -116,7 +117,7 @@ func (m *Monitor) Current() *Workspace {
116117 func (m *Monitor) newWorkspace() *Workspace {
117118 w := m.template
118119 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, ""
120121 return &w
121122 }
122123
@@ -255,8 +256,14 @@ func (m *Monitor) MoveToWorkspace(i int) {
255256 if !ok || m.Workspaces[i] == cur {
256257 return
257258 }
258- cur.RemoveWindow(id)
259- m.Workspaces[i].AddWindow(id)
259+ if f := cur.floatIndex(id); f >= 0 {
260+ fl := cur.Floats[f]
261+ cur.RemoveWindow(id)
262+ m.Workspaces[i].AddFloating(id, fl.W, fl.H)
263+ } else {
264+ cur.RemoveWindow(id)
265+ m.Workspaces[i].AddWindow(id)
266+ }
260267 m.normalize()
261268 }
262269
@@ -271,10 +278,8 @@ func (m *Monitor) Layout() []Placement {
271278 result = append(result, w.Layout()...)
272279 continue
273280 }
274- for _, c := range w.Columns {
275- for _, id := range c.Windows {
276- result = append(result, Placement{ID: id, Hidden: true})
277- }
281+ for _, id := range w.windows() {
282+ result = append(result, Placement{ID: id, Hidden: true})
278283 }
279284 }
280285 return result
@@ -296,6 +301,7 @@ func (m *Monitor) SetOutput(width, height int) {
296301 }
297302 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }
298303 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
304+func (m *Monitor) SetBorder(b int) { m.each(func(w *Workspace) { w.border = max(b, 0) }) }
299305 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
300306
301307 // SetMaxColumns sets the default; named workspaces may override it.
@@ -364,6 +370,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
364370 m.Workspaces[m.Active].AddWindow(id)
365371 }
366372 }
373+ for _, f := range w.Floats {
374+ m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
375+ }
367376 }
368377 m.hidden = hidden
369378 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.Gaps84 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.template118 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 &w121 return &w
121 }122 }
122 123
@@ -255,8 +256,14 @@ func (m *Monitor) MoveToWorkspace(i int) {
255 if !ok || m.Workspaces[i] == cur {256 if !ok || m.Workspaces[i] == cur {
256 return257 return
257 }258 }
258- cur.RemoveWindow(id)259+ if f := cur.floatIndex(id); f >= 0 {
259- m.Workspaces[i].AddWindow(id)260+ fl := cur.Floats[f]
261+ cur.RemoveWindow(id)
262+ m.Workspaces[i].AddFloating(id, fl.W, fl.H)
263+ } else {
264+ cur.RemoveWindow(id)
265+ m.Workspaces[i].AddWindow(id)
266+ }
260 m.normalize()267 m.normalize()
261 }268 }
262 269
@@ -271,10 +278,8 @@ func (m *Monitor) Layout() []Placement {
271 result = append(result, w.Layout()...)278 result = append(result, w.Layout()...)
272 continue279 continue
273 }280 }
274- for _, c := range w.Columns {281+ for _, id := range w.windows() {
275- for _, id := range c.Windows {282+ result = append(result, Placement{ID: id, Hidden: true})
276- result = append(result, Placement{ID: id, Hidden: true})
277- }
278 }283 }
279 }284 }
280 return result285 return result
@@ -296,6 +301,7 @@ func (m *Monitor) SetOutput(width, height int) {
296 }301 }
297 func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) }302 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) }) }303 func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) }
304+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) }) }305 func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
300 306
301 // SetMaxColumns sets the default; named workspaces may override it.307 // SetMaxColumns sets the default; named workspaces may override it.
@@ -364,6 +370,9 @@ func (m *Monitor) SetNamed(specs []NamedWorkspace) {
364 m.Workspaces[m.Active].AddWindow(id)370 m.Workspaces[m.Active].AddWindow(id)
365 }371 }
366 }372 }
373+ for _, f := range w.Floats {
374+ m.Workspaces[m.Active].AddFloating(f.ID, f.W, f.H)
375+ }
367 }376 }
368 m.hidden = hidden377 m.hidden = hidden
369 m.named = append([]NamedWorkspace(nil), specs...)378 m.named = append([]NamedWorkspace(nil), specs...)
A internal/core/popup.go +302 -0
@@ -0,0 +1,302 @@
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+ delete(c.popups, v.ID)
157+ return c.command(ctx, ports.ClosePopup{ID: v.ID})
158+ }
159+ o := sc.mon.Output()
160+ bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}
161+ p := c.popups[v.ID]
162+ if p == nil {
163+ p = &popupState{id: v.ID, parent: v.Parent}
164+ c.popups[v.ID] = p
165+ c.popupOrder = append(c.popupOrder, v.ID)
166+ }
167+ p.grab = v.Grab
168+ p.rect = place(v.Positioner, bounds)
169+ return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token})
170+}
171+
172+// dropPopup forgets a popup and dismisses its children.
173+func (c *Core) dropPopup(ctx context.Context, id WindowID) error {
174+ if c.popups[id] == nil {
175+ return nil
176+ }
177+ delete(c.popups, id)
178+ c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id })
179+ for _, child := range slices.Clone(c.popupOrder) {
180+ if p := c.popups[child]; p != nil && p.parent == id {
181+ if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil {
182+ return err
183+ }
184+ if err := c.dropPopup(ctx, child); err != nil {
185+ return err
186+ }
187+ }
188+ }
189+ if c.pointer == id {
190+ c.pointer = 0
191+ }
192+ return nil
193+}
194+
195+// dropPopupsOf dismisses the popups hanging from a window.
196+func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error {
197+ for _, child := range slices.Clone(c.popupOrder) {
198+ if p := c.popups[child]; p != nil && p.parent == id {
199+ if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil {
200+ return err
201+ }
202+ if err := c.dropPopup(ctx, child); err != nil {
203+ return err
204+ }
205+ }
206+ }
207+ return nil
208+}
209+
210+// closeHiddenPopups dismisses popups whose window left the screen.
211+func (c *Core) closeHiddenPopups(ctx context.Context) error {
212+ for _, id := range slices.Clone(c.popupOrder) {
213+ if c.popups[id] == nil {
214+ continue
215+ }
216+ if _, _, ok := c.windowRect(id); !ok {
217+ if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil {
218+ return err
219+ }
220+ if err := c.dropPopup(ctx, id); err != nil {
221+ return err
222+ }
223+ }
224+ }
225+ return nil
226+}
227+
228+// dismissGrabs closes the grabbing popups unless the click hit one of
229+// them: a click outside a menu closes it.
230+func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error {
231+ for _, id := range slices.Clone(c.popupOrder) {
232+ p := c.popups[id]
233+ if p == nil || !p.grab {
234+ continue
235+ }
236+ // A click on a popup of the same chain keeps it; a click on
237+ // anything else, its parent window included, closes it.
238+ if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) {
239+ continue
240+ }
241+ if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil {
242+ return err
243+ }
244+ if err := c.dropPopup(ctx, id); err != nil {
245+ return err
246+ }
247+ }
248+ return nil
249+}
250+
251+// isAncestor reports whether a is a popup ancestor of b.
252+func (c *Core) isAncestor(a, b WindowID) bool {
253+ for p := c.popups[b]; p != nil; p = c.popups[p.parent] {
254+ if p.parent == a {
255+ return true
256+ }
257+ }
258+ return false
259+}
260+
261+// grabFocus is the topmost mapped grabbing popup, which takes the keyboard.
262+func (c *Core) grabFocus() WindowID {
263+ for i := len(c.popupOrder) - 1; i >= 0; i-- {
264+ if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped {
265+ return p.id
266+ }
267+ }
268+ return 0
269+}
270+
271+// popupAt returns the topmost mapped popup under the output-local point.
272+func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) {
273+ for i := len(c.popupOrder) - 1; i >= 0; i-- {
274+ p := c.popups[c.popupOrder[i]]
275+ if p == nil || !p.mapped {
276+ continue
277+ }
278+ s, r, ok := c.windowRect(p.id)
279+ if !ok || s != sc {
280+ continue
281+ }
282+ if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) {
283+ return p.id, lx - float64(r.X), ly - float64(r.Y)
284+ }
285+ }
286+ return 0, 0, 0
287+}
288+
289+// scenePopups are the mapped popups on a screen, bottom to top.
290+func (c *Core) scenePopups(sc *screen) []ports.SceneWindow {
291+ var out []ports.SceneWindow
292+ for _, id := range c.popupOrder {
293+ p := c.popups[id]
294+ if p == nil || !p.mapped {
295+ continue
296+ }
297+ if s, r, ok := c.windowRect(id); ok && s == sc {
298+ out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true})
299+ }
300+ }
301+ return out
302+}
@@ -0,0 +1,302 @@
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+ delete(c.popups, v.ID)
157+ return c.command(ctx, ports.ClosePopup{ID: v.ID})
158+ }
159+ o := sc.mon.Output()
160+ bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H}
161+ p := c.popups[v.ID]
162+ if p == nil {
163+ p = &popupState{id: v.ID, parent: v.Parent}
164+ c.popups[v.ID] = p
165+ c.popupOrder = append(c.popupOrder, v.ID)
166+ }
167+ p.grab = v.Grab
168+ p.rect = place(v.Positioner, bounds)
169+ return c.command(ctx, ports.ConfigurePopup{ID: v.ID, Rect: p.rect, Reposition: v.Reposition, Token: v.Token})
170+}
171+
172+// dropPopup forgets a popup and dismisses its children.
173+func (c *Core) dropPopup(ctx context.Context, id WindowID) error {
174+ if c.popups[id] == nil {
175+ return nil
176+ }
177+ delete(c.popups, id)
178+ c.popupOrder = slices.DeleteFunc(c.popupOrder, func(v WindowID) bool { return v == id })
179+ for _, child := range slices.Clone(c.popupOrder) {
180+ if p := c.popups[child]; p != nil && p.parent == id {
181+ if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil {
182+ return err
183+ }
184+ if err := c.dropPopup(ctx, child); err != nil {
185+ return err
186+ }
187+ }
188+ }
189+ if c.pointer == id {
190+ c.pointer = 0
191+ }
192+ return nil
193+}
194+
195+// dropPopupsOf dismisses the popups hanging from a window.
196+func (c *Core) dropPopupsOf(ctx context.Context, id WindowID) error {
197+ for _, child := range slices.Clone(c.popupOrder) {
198+ if p := c.popups[child]; p != nil && p.parent == id {
199+ if err := c.command(ctx, ports.ClosePopup{ID: child}); err != nil {
200+ return err
201+ }
202+ if err := c.dropPopup(ctx, child); err != nil {
203+ return err
204+ }
205+ }
206+ }
207+ return nil
208+}
209+
210+// closeHiddenPopups dismisses popups whose window left the screen.
211+func (c *Core) closeHiddenPopups(ctx context.Context) error {
212+ for _, id := range slices.Clone(c.popupOrder) {
213+ if c.popups[id] == nil {
214+ continue
215+ }
216+ if _, _, ok := c.windowRect(id); !ok {
217+ if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil {
218+ return err
219+ }
220+ if err := c.dropPopup(ctx, id); err != nil {
221+ return err
222+ }
223+ }
224+ }
225+ return nil
226+}
227+
228+// dismissGrabs closes the grabbing popups unless the click hit one of
229+// them: a click outside a menu closes it.
230+func (c *Core) dismissGrabs(ctx context.Context, hit WindowID) error {
231+ for _, id := range slices.Clone(c.popupOrder) {
232+ p := c.popups[id]
233+ if p == nil || !p.grab {
234+ continue
235+ }
236+ // A click on a popup of the same chain keeps it; a click on
237+ // anything else, its parent window included, closes it.
238+ if c.popups[hit] != nil && (hit == id || c.isAncestor(id, hit) || c.isAncestor(hit, id)) {
239+ continue
240+ }
241+ if err := c.command(ctx, ports.ClosePopup{ID: id}); err != nil {
242+ return err
243+ }
244+ if err := c.dropPopup(ctx, id); err != nil {
245+ return err
246+ }
247+ }
248+ return nil
249+}
250+
251+// isAncestor reports whether a is a popup ancestor of b.
252+func (c *Core) isAncestor(a, b WindowID) bool {
253+ for p := c.popups[b]; p != nil; p = c.popups[p.parent] {
254+ if p.parent == a {
255+ return true
256+ }
257+ }
258+ return false
259+}
260+
261+// grabFocus is the topmost mapped grabbing popup, which takes the keyboard.
262+func (c *Core) grabFocus() WindowID {
263+ for i := len(c.popupOrder) - 1; i >= 0; i-- {
264+ if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped {
265+ return p.id
266+ }
267+ }
268+ return 0
269+}
270+
271+// popupAt returns the topmost mapped popup under the output-local point.
272+func (c *Core) popupAt(sc *screen, lx, ly float64) (WindowID, float64, float64) {
273+ for i := len(c.popupOrder) - 1; i >= 0; i-- {
274+ p := c.popups[c.popupOrder[i]]
275+ if p == nil || !p.mapped {
276+ continue
277+ }
278+ s, r, ok := c.windowRect(p.id)
279+ if !ok || s != sc {
280+ continue
281+ }
282+ if lx >= float64(r.X) && lx < float64(r.X+r.W) && ly >= float64(r.Y) && ly < float64(r.Y+r.H) {
283+ return p.id, lx - float64(r.X), ly - float64(r.Y)
284+ }
285+ }
286+ return 0, 0, 0
287+}
288+
289+// scenePopups are the mapped popups on a screen, bottom to top.
290+func (c *Core) scenePopups(sc *screen) []ports.SceneWindow {
291+ var out []ports.SceneWindow
292+ for _, id := range c.popupOrder {
293+ p := c.popups[id]
294+ if p == nil || !p.mapped {
295+ continue
296+ }
297+ if s, r, ok := c.windowRect(id); ok && s == sc {
298+ out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true})
299+ }
300+ }
301+ return out
302+}
A internal/core/popup_test.go +62 -0
@@ -0,0 +1,62 @@
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+ w.RemoveWindow(2)
59+ if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 {
60+ t.Fatalf("focus after close %d", id)
61+ }
62+}
@@ -0,0 +1,62 @@
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+ w.RemoveWindow(2)
59+ if id, _ := w.Focused(); id != 1 || len(w.Floats) != 0 {
60+ t.Fatalf("focus after close %d", id)
61+ }
62+}
M internal/core/screens.go +1 -0
@@ -303,6 +303,7 @@ func (c *Core) settings(m *Monitor) {
303303 m.SetMaxColumns(c.cfg.Layout.MaxColumns)
304304 m.SetPresets(c.presets)
305305 m.SetGaps(c.cfg.Layout.Gaps)
306+ m.SetBorder(c.cfg.Border.Width)
306307 }
307308
308309 // 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 focused309 // 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 {
3838 st.Outputs = append(st.Outputs, o)
3939 for _, w := range m.all() {
4040 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})
4644 }
4745 }
4846 }
@@ -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) + 140 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 {
1313 Slot string
1414 // PID is the client process, 0 when unknown.
1515 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
1621 }
1722
1823 // SlotEnv is the environment variable nefertty sets on processes it spawns
@@ -42,6 +47,68 @@ type WindowAppID struct {
4247
4348 func (WindowAppID) clientEvent() {}
4449
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+
45112 // Layer is a wlr-layer-shell stacking layer.
46113 type Layer uint32
47114
@@ -228,7 +295,9 @@ type ConfigureWindow struct {
228295 ID WindowID
229296 Width, Height int
230297 Fullscreen, Activated bool
231- Output string
298+ // Floating windows are not tiled: no tiled states.
299+ Floating bool
300+ Output string
232301 }
233302
234303 func (ConfigureWindow) clientCommand() {}
@@ -304,6 +373,22 @@ type FocusWindow struct{ ID WindowID }
304373
305374 func (FocusWindow) clientCommand() {}
306375
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+
307392 // PointerFocus changes the pointer surface; ID 0 clears focus. Coordinates are surface-local.
308393 type PointerFocus struct {
309394 ID WindowID
@@ -509,6 +594,8 @@ type SceneWindow struct {
509594 Focused, Fullscreen, Hidden bool
510595 // Borderless windows fill the usable width alone; no border is drawn.
511596 Borderless bool
597+ // Popups are drawn from their content only: no border, no background.
598+ Popup bool
512599 }
513600
514601 // State carries core → state publisher a snapshot of what is on screen, for
@@ -13,6 +13,11 @@ type WindowMapped struct {
13 Slot string13 Slot string
14 // PID is the client process, 0 when unknown.14 // PID is the client process, 0 when unknown.
15 PID int15 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 spawns23 // 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 uint32113 type Layer uint32
47 114
@@ -228,7 +295,9 @@ type ConfigureWindow struct {
228 ID WindowID295 ID WindowID
229 Width, Height int296 Width, Height int
230 Fullscreen, Activated bool297 Fullscreen, Activated bool
231- Output string298+ // 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 WindowID394 ID WindowID
@@ -509,6 +594,8 @@ type SceneWindow struct {
509 Focused, Fullscreen, Hidden bool594 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 bool596 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, for601 // State carries core → state publisher a snapshot of what is on screen, for

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close