detached 0da7456

fix(shell): keep floating dialogs above fullscreen windows and nest popup grabs

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

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 26

Tags 10

↑↓ moveenter openesc close