detached 10fab51

fix(core): keep binds, moves and popup grabs off windows hidden by fullscreen

commit
10fab5127ff2ec0c276e6bb9b2db6758246c158a
author
brice · · 2026-09-28 14:29 UTC
committer
brice · · 2026-09-28 14:44 UTC
parent
8abeded
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +146 -5
M internal/core/action.go +3 -0
@@ -128,6 +128,9 @@ type Effect struct {
128128 // screen; column focus past the edge column moves to the neighbor screen;
129129 // everything else applies to the focused screen's monitor.
130130 func (c *Core) applyAction(a Action) Effect {
131+ // A bind acts on the window on screen: under a covering fullscreen
132+ // window, that is the one Focused reports.
133+ c.cur().mon.Current().focusCover()
131134 dir := 0
132135 switch a {
133136 case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
@@ -128,6 +128,9 @@ type Effect struct {
128 // screen; column focus past the edge column moves to the neighbor screen;128 // screen; column focus past the edge column moves to the neighbor screen;
129 // everything else applies to the focused screen's monitor.129 // everything else applies to the focused screen's monitor.
130 func (c *Core) applyAction(a Action) Effect {130 func (c *Core) applyAction(a Action) Effect {
131+ // A bind acts on the window on screen: under a covering fullscreen
132+ // window, that is the one Focused reports.
133+ c.cur().mon.Current().focusCover()
131 dir := 0134 dir := 0
132 switch a {135 switch a {
133 case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:136 case ActionFocusMonitorLeft, ActionMoveWorkspaceLeft:
M internal/core/floating_test.go +22 -0
@@ -102,6 +102,28 @@ func TestExclusiveFullscreenNewWindow(t *testing.T) {
102102 }
103103 }
104104
105+// Under a covering fullscreen float, or any fixed-overflow fullscreen,
106+// focus moves inside the workspace are off and binds act on the window on
107+// screen, never on a column it hides.
108+func TestFullscreenPinsFocus(t *testing.T) {
109+ w := workspace()
110+ w.AddWindow(1)
111+ w.AddWindow(2)
112+ w.AddFloating(3, 20, 10)
113+ w.SetFullscreen(3, true)
114+ w.FocusColumn(-1)
115+ w.FocusWindow(1)
116+ w.focusCover()
117+ w.Apply(ActionMoveColumnLeft)
118+ w.Apply(ActionCycleColumnWidth)
119+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {
120+ t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
121+ }
122+ if e := w.Apply(ActionCloseWindow); e.Close != 3 {
123+ t.Fatalf("close %d, want the fullscreen window", e.Close)
124+ }
125+}
126+
105127 // A fullscreen window does not float until it leaves fullscreen.
106128 func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) {
107129 w := workspace()
@@ -102,6 +102,28 @@ func TestExclusiveFullscreenNewWindow(t *testing.T) {
102 }102 }
103 }103 }
104 104
105+// Under a covering fullscreen float, or any fixed-overflow fullscreen,
106+// focus moves inside the workspace are off and binds act on the window on
107+// screen, never on a column it hides.
108+func TestFullscreenPinsFocus(t *testing.T) {
109+ w := workspace()
110+ w.AddWindow(1)
111+ w.AddWindow(2)
112+ w.AddFloating(3, 20, 10)
113+ w.SetFullscreen(3, true)
114+ w.FocusColumn(-1)
115+ w.FocusWindow(1)
116+ w.focusCover()
117+ w.Apply(ActionMoveColumnLeft)
118+ w.Apply(ActionCycleColumnWidth)
119+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {
120+ t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
121+ }
122+ if e := w.Apply(ActionCloseWindow); e.Close != 3 {
123+ t.Fatalf("close %d, want the fullscreen window", e.Close)
124+ }
125+}
126+
105 // A fullscreen window does not float until it leaves fullscreen.127 // A fullscreen window does not float until it leaves fullscreen.
106 func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) {128 func TestToggleWindowFloatingIgnoresFullscreen(t *testing.T) {
107 w := workspace()129 w := workspace()
M internal/core/layout.go +37 -1
@@ -415,6 +415,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
415415 return false
416416 }
417417 func (w *Workspace) FocusColumn(dir int) {
418+ if w.pinned() {
419+ return
420+ }
418421 if w.floatFocus {
419422 // The first move leaves the floating window for the columns.
420423 w.floatFocus = false
@@ -434,7 +437,7 @@ func (w *Workspace) FocusColumn(dir int) {
434437 // overflow stacks columns (spiral, expanded strips), so it follows the
435438 // screen: the column on that side.
436439 func (w *Workspace) columnToward(dir int) int {
437- if len(w.Columns) == 0 || (dir != -1 && dir != 1) {
440+ if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
438441 return -1
439442 }
440443 if w.onScreenFocus() {
@@ -456,6 +459,9 @@ func (w *Workspace) onScreenFocus() bool {
456459 // Fixed overflow also stacks columns (spiral, expanded strips): past the
457460 // column edge, focus goes to the column on screen above or below.
458461 func (w *Workspace) FocusWindow(dir int) bool {
462+ if w.pinned() {
463+ return false
464+ }
459465 if w.floatFocus {
460466 w.floatFocus = false
461467 return true
@@ -703,6 +709,36 @@ func (w *Workspace) SetMaxColumns(n int) {
703709 w.scroll()
704710 }
705711 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
712+// pinned reports whether focus moves inside the workspace are off: the
713+// covering fullscreen window hides every target. Only in scroll overflow
714+// does moving to a tiled neighbor scroll it off and show the target.
715+// Moves to another workspace or monitor still work.
716+func (w *Workspace) pinned() bool {
717+ c := w.cover()
718+ return c != 0 && (w.Overflow == OverflowFixed || w.floatIndex(c) >= 0)
719+}
720+
721+// focusCover points the focus state at the covering fullscreen window,
722+// which Focused already reports, so a user action acts on the window on
723+// screen, not on one it hides.
724+func (w *Workspace) focusCover() {
725+ c := w.cover()
726+ if c == 0 {
727+ return
728+ }
729+ if w.floatIndex(c) >= 0 {
730+ w.floatFocus = true
731+ w.FocusID(c)
732+ return
733+ }
734+ w.floatFocus = false
735+ for i, col := range w.Columns {
736+ if j := slices.Index(col.Windows, c); j >= 0 {
737+ w.Focus, w.Columns[i].Focus = i, j
738+ }
739+ }
740+}
741+
706742 // cover returns the fullscreen window that covers the output, or 0. The
707743 // fullscreen is exclusive: while it covers, no other window of the
708744 // workspace is drawn, so the output can scan it out.
@@ -415,6 +415,9 @@ func (w *Workspace) FocusID(id WindowID) bool {
415 return false415 return false
416 }416 }
417 func (w *Workspace) FocusColumn(dir int) {417 func (w *Workspace) FocusColumn(dir int) {
418+ if w.pinned() {
419+ return
420+ }
418 if w.floatFocus {421 if w.floatFocus {
419 // The first move leaves the floating window for the columns.422 // The first move leaves the floating window for the columns.
420 w.floatFocus = false423 w.floatFocus = false
@@ -434,7 +437,7 @@ func (w *Workspace) FocusColumn(dir int) {
434 // overflow stacks columns (spiral, expanded strips), so it follows the437 // overflow stacks columns (spiral, expanded strips), so it follows the
435 // screen: the column on that side.438 // screen: the column on that side.
436 func (w *Workspace) columnToward(dir int) int {439 func (w *Workspace) columnToward(dir int) int {
437- if len(w.Columns) == 0 || (dir != -1 && dir != 1) {440+ if len(w.Columns) == 0 || (dir != -1 && dir != 1) || w.pinned() {
438 return -1441 return -1
439 }442 }
440 if w.onScreenFocus() {443 if w.onScreenFocus() {
@@ -456,6 +459,9 @@ func (w *Workspace) onScreenFocus() bool {
456 // Fixed overflow also stacks columns (spiral, expanded strips): past the459 // Fixed overflow also stacks columns (spiral, expanded strips): past the
457 // column edge, focus goes to the column on screen above or below.460 // column edge, focus goes to the column on screen above or below.
458 func (w *Workspace) FocusWindow(dir int) bool {461 func (w *Workspace) FocusWindow(dir int) bool {
462+ if w.pinned() {
463+ return false
464+ }
459 if w.floatFocus {465 if w.floatFocus {
460 w.floatFocus = false466 w.floatFocus = false
461 return true467 return true
@@ -703,6 +709,36 @@ func (w *Workspace) SetMaxColumns(n int) {
703 w.scroll()709 w.scroll()
704 }710 }
705 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }711 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
712+// pinned reports whether focus moves inside the workspace are off: the
713+// covering fullscreen window hides every target. Only in scroll overflow
714+// does moving to a tiled neighbor scroll it off and show the target.
715+// Moves to another workspace or monitor still work.
716+func (w *Workspace) pinned() bool {
717+ c := w.cover()
718+ return c != 0 && (w.Overflow == OverflowFixed || w.floatIndex(c) >= 0)
719+}
720+
721+// focusCover points the focus state at the covering fullscreen window,
722+// which Focused already reports, so a user action acts on the window on
723+// screen, not on one it hides.
724+func (w *Workspace) focusCover() {
725+ c := w.cover()
726+ if c == 0 {
727+ return
728+ }
729+ if w.floatIndex(c) >= 0 {
730+ w.floatFocus = true
731+ w.FocusID(c)
732+ return
733+ }
734+ w.floatFocus = false
735+ for i, col := range w.Columns {
736+ if j := slices.Index(col.Windows, c); j >= 0 {
737+ w.Focus, w.Columns[i].Focus = i, j
738+ }
739+ }
740+}
741+
706 // cover returns the fullscreen window that covers the output, or 0. The742 // cover returns the fullscreen window that covers the output, or 0. The
707 // fullscreen is exclusive: while it covers, no other window of the743 // fullscreen is exclusive: while it covers, no other window of the
708 // workspace is drawn, so the output can scan it out.744 // workspace is drawn, so the output can scan it out.
M internal/core/monitor.go +16 -1
@@ -503,12 +503,27 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
503503 if !ok || to == cur {
504504 return
505505 }
506+ if m.followMove && to.origin != nil {
507+ // The view follows the window: a fullscreen workspace would hide
508+ // it, so its window goes home first and the move lands there.
509+ origin := to.origin
510+ m.leaveFullscreen(to, false)
511+ to = origin
512+ if i = indexOf(m.Workspaces, to); i < 0 || to == cur {
513+ return
514+ }
515+ }
506516 if f := cur.floatIndex(id); f >= 0 {
507517 fl := cur.Floats[f]
508518 cur.RemoveWindow(id)
509519 to.AddFloating(id, fl.W, fl.H)
510520 to.Floats[len(to.Floats)-1] = fl
511- to.FocusID(id)
521+ if to.origin == nil {
522+ // As receive: an in-place fullscreen would hide it.
523+ to.Activate(id)
524+ } else {
525+ to.FocusID(id)
526+ }
512527 } else {
513528 col := Column{Windows: []WindowID{id}}
514529 ok := true
@@ -503,12 +503,27 @@ func (m *Monitor) MoveToWorkspace(i int, column bool) {
503 if !ok || to == cur {503 if !ok || to == cur {
504 return504 return
505 }505 }
506+ if m.followMove && to.origin != nil {
507+ // The view follows the window: a fullscreen workspace would hide
508+ // it, so its window goes home first and the move lands there.
509+ origin := to.origin
510+ m.leaveFullscreen(to, false)
511+ to = origin
512+ if i = indexOf(m.Workspaces, to); i < 0 || to == cur {
513+ return
514+ }
515+ }
506 if f := cur.floatIndex(id); f >= 0 {516 if f := cur.floatIndex(id); f >= 0 {
507 fl := cur.Floats[f]517 fl := cur.Floats[f]
508 cur.RemoveWindow(id)518 cur.RemoveWindow(id)
509 to.AddFloating(id, fl.W, fl.H)519 to.AddFloating(id, fl.W, fl.H)
510 to.Floats[len(to.Floats)-1] = fl520 to.Floats[len(to.Floats)-1] = fl
511- to.FocusID(id)521+ if to.origin == nil {
522+ // As receive: an in-place fullscreen would hide it.
523+ to.Activate(id)
524+ } else {
525+ to.FocusID(id)
526+ }
512 } else {527 } else {
513 col := Column{Windows: []WindowID{id}}528 col := Column{Windows: []WindowID{id}}
514 ok := true529 ok := true
M internal/core/monitor_test.go +32 -0
@@ -739,3 +739,35 @@ func TestMaximizeLeavesClientFullscreen(t *testing.T) {
739739 })
740740 }
741741 }
742+
743+// A move that the view follows into a fixed-overflow fullscreen workspace
744+// sends its window home first: the moved window must be seen, tiled or
745+// floating.
746+func TestFollowMoveIntoFullscreenLands(t *testing.T) {
747+ for _, floating := range []bool{false, true} {
748+ m := newMonitor("", "")
749+ m.SetOutput(100, 80)
750+ m.SetOverflow(OverflowFixed)
751+ m.SetFollowMove(true)
752+ m.AddWindow(1)
753+ m.AddWindow(2)
754+ m.ToggleFullscreen() // 2 on its own workspace, below 1's
755+ m.Focus(0)
756+ m.Focus(len(m.Workspaces) - 1)
757+ if floating {
758+ m.AddFloating(5, 10, 10)
759+ } else {
760+ m.AddWindow(5)
761+ }
762+ m.MoveToWorkspace(1, true)
763+ w := m.Current()
764+ if id, _ := w.Focused(); id != 5 || w.cover() != 0 {
765+ t.Fatalf("floating %v: focused %d cover %d, workspaces %v", floating, id, w.cover(), windows(m))
766+ }
767+ for _, p := range m.Layout() {
768+ if p.ID == 5 && p.Hidden {
769+ t.Fatalf("floating %v: moved window hidden", floating)
770+ }
771+ }
772+ }
773+}
@@ -739,3 +739,35 @@ func TestMaximizeLeavesClientFullscreen(t *testing.T) {
739 })739 })
740 }740 }
741 }741 }
742+
743+// A move that the view follows into a fixed-overflow fullscreen workspace
744+// sends its window home first: the moved window must be seen, tiled or
745+// floating.
746+func TestFollowMoveIntoFullscreenLands(t *testing.T) {
747+ for _, floating := range []bool{false, true} {
748+ m := newMonitor("", "")
749+ m.SetOutput(100, 80)
750+ m.SetOverflow(OverflowFixed)
751+ m.SetFollowMove(true)
752+ m.AddWindow(1)
753+ m.AddWindow(2)
754+ m.ToggleFullscreen() // 2 on its own workspace, below 1's
755+ m.Focus(0)
756+ m.Focus(len(m.Workspaces) - 1)
757+ if floating {
758+ m.AddFloating(5, 10, 10)
759+ } else {
760+ m.AddWindow(5)
761+ }
762+ m.MoveToWorkspace(1, true)
763+ w := m.Current()
764+ if id, _ := w.Focused(); id != 5 || w.cover() != 0 {
765+ t.Fatalf("floating %v: focused %d cover %d, workspaces %v", floating, id, w.cover(), windows(m))
766+ }
767+ for _, p := range m.Layout() {
768+ if p.ID == 5 && p.Hidden {
769+ t.Fatalf("floating %v: moved window hidden", floating)
770+ }
771+ }
772+ }
773+}
M internal/core/popup.go +3 -1
@@ -272,7 +272,9 @@ func (c *Core) isAncestor(a, b WindowID) bool {
272272 // grabFocus is the topmost mapped grabbing popup, which takes the keyboard.
273273 func (c *Core) grabFocus() WindowID {
274274 for i := len(c.popupOrder) - 1; i >= 0; i-- {
275- if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped {
275+ // A popup hidden with its parent (under a fullscreen window) has
276+ // no keyboard.
277+ if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped && c.visible(p.id) {
276278 return p.id
277279 }
278280 }
@@ -272,7 +272,9 @@ func (c *Core) isAncestor(a, b WindowID) bool {
272 // grabFocus is the topmost mapped grabbing popup, which takes the keyboard.272 // grabFocus is the topmost mapped grabbing popup, which takes the keyboard.
273 func (c *Core) grabFocus() WindowID {273 func (c *Core) grabFocus() WindowID {
274 for i := len(c.popupOrder) - 1; i >= 0; i-- {274 for i := len(c.popupOrder) - 1; i >= 0; i-- {
275- if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped {275+ // A popup hidden with its parent (under a fullscreen window) has
276+ // no keyboard.
277+ if p := c.popups[c.popupOrder[i]]; p != nil && p.grab && p.mapped && c.visible(p.id) {
276 return p.id278 return p.id
277 }279 }
278 }280 }
M internal/core/popup_test.go +29 -0
@@ -72,3 +72,32 @@ func TestFloatingWindows(t *testing.T) {
7272 t.Fatalf("focus after close %d", id)
7373 }
7474 }
75+
76+// A grabbing popup hidden with its parent under a fullscreen window has no
77+// keyboard; the fullscreen window has it.
78+func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
79+ cfg := ports.Config{}
80+ cfg.Keyboard.CmdKey = "super"
81+ cfg.Layout.MaxColumns = 2
82+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
87+ m := c.cur().mon
88+ m.AddWindow(1)
89+ m.AddWindow(2)
90+ c.popups[3] = &popupState{id: 3, parent: 1, grab: true, mapped: true, rect: Rect{W: 5, H: 5}}
91+ c.popupOrder = append(c.popupOrder, 3)
92+ m.Current().FocusID(1)
93+ if f := c.keyboardFocus(); f != 3 {
94+ t.Fatalf("focus %d, want the grabbing popup", f)
95+ }
96+ m.SetFullscreen(2, true)
97+ // Scroll overflow: the fullscreen window covers once its column is on
98+ // screen.
99+ m.Current().FocusID(2)
100+ if f := c.keyboardFocus(); f != 2 {
101+ t.Fatalf("focus %d, want the fullscreen window", f)
102+ }
103+}
@@ -72,3 +72,32 @@ func TestFloatingWindows(t *testing.T) {
72 t.Fatalf("focus after close %d", id)72 t.Fatalf("focus after close %d", id)
73 }73 }
74 }74 }
75+
76+// A grabbing popup hidden with its parent under a fullscreen window has no
77+// keyboard; the fullscreen window has it.
78+func TestHiddenGrabPopupLosesKeyboard(t *testing.T) {
79+ cfg := ports.Config{}
80+ cfg.Keyboard.CmdKey = "super"
81+ cfg.Layout.MaxColumns = 2
82+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)})
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80})
87+ m := c.cur().mon
88+ m.AddWindow(1)
89+ m.AddWindow(2)
90+ c.popups[3] = &popupState{id: 3, parent: 1, grab: true, mapped: true, rect: Rect{W: 5, H: 5}}
91+ c.popupOrder = append(c.popupOrder, 3)
92+ m.Current().FocusID(1)
93+ if f := c.keyboardFocus(); f != 3 {
94+ t.Fatalf("focus %d, want the grabbing popup", f)
95+ }
96+ m.SetFullscreen(2, true)
97+ // Scroll overflow: the fullscreen window covers once its column is on
98+ // screen.
99+ m.Current().FocusID(2)
100+ if f := c.keyboardFocus(); f != 2 {
101+ t.Fatalf("focus %d, want the fullscreen window", f)
102+ }
103+}
M internal/ports/messages.go +4 -2
@@ -752,8 +752,10 @@ type Scene struct {
752752 // the Border colors: tile lines over the tiles, under the floats; a
753753 // float's border right after the float.
754754 Separators []Separator
755- // Layers are drawn in slice order: background and bottom before windows,
756- // top and overlay after. A fullscreen window covers bottom and top.
755+ // Layers are the shown layer surfaces, drawn in slice order: background
756+ // and bottom before windows, top and overlay after. Core leaves out
757+ // those a fullscreen window hides (all but the background and a surface
758+ // taking the keyboard exclusively); adapters draw every layer given.
757759 // Window popups are drawn after the windows, layer popups
758760 // (SceneWindow.OverLayers) last, over every layer.
759761 Layers []SceneLayer
@@ -752,8 +752,10 @@ type Scene struct {
752 // the Border colors: tile lines over the tiles, under the floats; a752 // the Border colors: tile lines over the tiles, under the floats; a
753 // float's border right after the float.753 // float's border right after the float.
754 Separators []Separator754 Separators []Separator
755- // Layers are drawn in slice order: background and bottom before windows,755+ // Layers are the shown layer surfaces, drawn in slice order: background
756- // top and overlay after. A fullscreen window covers bottom and top.756+ // and bottom before windows, top and overlay after. Core leaves out
757+ // those a fullscreen window hides (all but the background and a surface
758+ // taking the keyboard exclusively); adapters draw every layer given.
757 // Window popups are drawn after the windows, layer popups759 // Window popups are drawn after the windows, layer popups
758 // (SceneWindow.OverLayers) last, over every layer.760 // (SceneWindow.OverLayers) last, over every layer.
759 Layers []SceneLayer761 Layers []SceneLayer

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 25

Tags 10

↑↓ moveenter openesc close