detached c2347ef

fix(core): drop the fullscreen latch once its window has the focus

The latch only cleared when its window asked for fullscreen while
focused, so a refocus without a request left it stale and later
requests were refused. Core now settles it at each publish outside the
overview, and a column moved away drops it.
commit
c2347ef1042d568bac4eb9366800150d594be411
author
brice · · 2026-10-02 15:41 UTC
parent
adeb040
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +151 -3
M internal/core/core.go +6 -0
@@ -483,6 +483,12 @@ func (c *Core) publish(ctx context.Context) error {
483483 if c.security.Protected {
484484 return c.publishProtected(ctx)
485485 }
486+ for _, sc := range c.screens {
487+ // The overview's selection is provisional: it focuses nothing yet.
488+ if !sc.mon.ov.open {
489+ sc.mon.Current().settleLeft()
490+ }
491+ }
486492 c.captureExpire()
487493 capture, err := c.captureEvaluate(ctx)
488494 if err != nil {
@@ -483,6 +483,12 @@ func (c *Core) publish(ctx context.Context) error {
483 if c.security.Protected {483 if c.security.Protected {
484 return c.publishProtected(ctx)484 return c.publishProtected(ctx)
485 }485 }
486+ for _, sc := range c.screens {
487+ // The overview's selection is provisional: it focuses nothing yet.
488+ if !sc.mon.ov.open {
489+ sc.mon.Current().settleLeft()
490+ }
491+ }
486 c.captureExpire()492 c.captureExpire()
487 capture, err := c.captureEvaluate(ctx)493 capture, err := c.captureEvaluate(ctx)
488 if err != nil {494 if err != nil {
A internal/core/fullscreen_left_core_test.go +80 -0
@@ -0,0 +1,80 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Through core, scroll overflow: an app whose fullscreen the user left by
12+// activating another window cannot cover it again by asking at once; once
13+// activated again, it may ask and is honoured.
14+func TestCoreLeftFullscreenLatch(t *testing.T) {
15+ client := make(chan ports.ClientEvent)
16+ output := make(chan ports.OutputEvent)
17+ commands := make(chan ports.ClientCommand, 64)
18+ scenes := make(chan []ports.Scene, 1)
19+ cfg := ports.Config{}
20+ cfg.Keyboard.CmdKey = "super"
21+ cfg.Layout.MaxColumns = 2
22+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+ done := make(chan struct{})
29+ go func() { defer close(done); _ = c.Run(ctx) }()
30+ go func() {
31+ for range commands {
32+ }
33+ }()
34+ full := func() ports.WindowID {
35+ t.Helper()
36+ select {
37+ case s := <-scenes:
38+ for _, w := range s[0].Windows {
39+ if w.Fullscreen && !w.Hidden {
40+ return w.ID
41+ }
42+ }
43+ return 0
44+ case <-time.After(time.Second):
45+ t.Fatal("scene timeout")
46+ return 0
47+ }
48+ }
49+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
50+ full()
51+ client <- ports.WindowMapped{ID: 1}
52+ full()
53+ client <- ports.WindowMapped{ID: 2}
54+ full()
55+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
56+ if id := full(); id != 2 {
57+ t.Fatalf("fullscreen %d, want 2", id)
58+ }
59+ client <- ports.WindowActivate{ID: 1}
60+ if id := full(); id != 0 {
61+ t.Fatalf("after activation: fullscreen %d", id)
62+ }
63+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
64+ if id := full(); id != 0 {
65+ t.Fatalf("re-request covered: fullscreen %d", id)
66+ }
67+ client <- ports.WindowActivate{ID: 2}
68+ full()
69+ client <- ports.WindowActivate{ID: 1}
70+ full()
71+ // The latch settled when 2 had the focus: scroll overflow honours an
72+ // unfocused window's request again.
73+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
74+ if id := full(); id != 2 {
75+ t.Fatalf("after refocus: fullscreen %d, want 2", id)
76+ }
77+ cancel()
78+ <-done
79+ close(commands)
80+}
@@ -0,0 +1,80 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// Through core, scroll overflow: an app whose fullscreen the user left by
12+// activating another window cannot cover it again by asking at once; once
13+// activated again, it may ask and is honoured.
14+func TestCoreLeftFullscreenLatch(t *testing.T) {
15+ client := make(chan ports.ClientEvent)
16+ output := make(chan ports.OutputEvent)
17+ commands := make(chan ports.ClientCommand, 64)
18+ scenes := make(chan []ports.Scene, 1)
19+ cfg := ports.Config{}
20+ cfg.Keyboard.CmdKey = "super"
21+ cfg.Layout.MaxColumns = 2
22+ c, err := New(cfg, Channels{Client: client, Output: output, Commands: commands, Scenes: scenes, Clock: laterClock(t)})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+ done := make(chan struct{})
29+ go func() { defer close(done); _ = c.Run(ctx) }()
30+ go func() {
31+ for range commands {
32+ }
33+ }()
34+ full := func() ports.WindowID {
35+ t.Helper()
36+ select {
37+ case s := <-scenes:
38+ for _, w := range s[0].Windows {
39+ if w.Fullscreen && !w.Hidden {
40+ return w.ID
41+ }
42+ }
43+ return 0
44+ case <-time.After(time.Second):
45+ t.Fatal("scene timeout")
46+ return 0
47+ }
48+ }
49+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
50+ full()
51+ client <- ports.WindowMapped{ID: 1}
52+ full()
53+ client <- ports.WindowMapped{ID: 2}
54+ full()
55+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
56+ if id := full(); id != 2 {
57+ t.Fatalf("fullscreen %d, want 2", id)
58+ }
59+ client <- ports.WindowActivate{ID: 1}
60+ if id := full(); id != 0 {
61+ t.Fatalf("after activation: fullscreen %d", id)
62+ }
63+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
64+ if id := full(); id != 0 {
65+ t.Fatalf("re-request covered: fullscreen %d", id)
66+ }
67+ client <- ports.WindowActivate{ID: 2}
68+ full()
69+ client <- ports.WindowActivate{ID: 1}
70+ full()
71+ // The latch settled when 2 had the focus: scroll overflow honours an
72+ // unfocused window's request again.
73+ client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
74+ if id := full(); id != 2 {
75+ t.Fatalf("after refocus: fullscreen %d, want 2", id)
76+ }
77+ cancel()
78+ <-done
79+ close(commands)
80+}
M internal/core/fullscreen_left_test.go +49 -0
@@ -15,6 +15,8 @@ func TestLeftFullscreenRefusesRequest(t *testing.T) {
1515 m.OverviewPick(1)
1616 },
1717 }
18+ // Fixed overflow refuses an unfocused window's request anyway; scroll
19+ // with a float is where the latch alone refuses it.
1820 for _, tc := range pinnedCases {
1921 for name, leave := range leaves {
2022 t.Run(tc.name+"/"+name, func(t *testing.T) {
@@ -53,6 +55,53 @@ func TestToggleFullscreenOffNoLatch(t *testing.T) {
5355 }
5456 }
5557
58+// Focusing the window again drops the latch even without a request, so a
59+// later request while unfocused follows the usual rules again.
60+func TestLeftFullscreenSettlesOnFocus(t *testing.T) {
61+ m := coverMonitor(OverflowScroll, true)
62+ w := m.Current()
63+ w.Activate(1)
64+ w.FocusID(2)
65+ w.settleLeft()
66+ if w.left != 0 {
67+ t.Fatalf("left %d", w.left)
68+ }
69+ w.FocusID(1)
70+ m.SetFullscreen(2, true)
71+ if w.fullscreen != 2 {
72+ t.Fatalf("request refused: %d", w.fullscreen)
73+ }
74+}
75+
76+// A taskbar fullscreen request activates the window first (core), so it
77+// passes the latch.
78+func TestLeftFullscreenTaskbar(t *testing.T) {
79+ m := coverMonitor(OverflowScroll, true)
80+ w := m.Current()
81+ w.Activate(1)
82+ w.Activate(2)
83+ m.SetFullscreen(2, true)
84+ if w.fullscreen != 2 || w.left != 0 {
85+ t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left)
86+ }
87+}
88+
89+// A latched window moved away with its column drops the latch.
90+func TestLeftFullscreenColumnMoved(t *testing.T) {
91+ m := monitor()
92+ m.AddWindow(1)
93+ m.AddWindow(2)
94+ w := m.Current()
95+ m.ToggleFullscreen()
96+ w.Activate(1)
97+ w.FocusID(2)
98+ w.left = 2 // as if moved before the latch settled
99+ m.MoveToWorkspace(1, true)
100+ if w.left != 0 {
101+ t.Fatalf("left %d", w.left)
102+ }
103+}
104+
56105 // A latched window that goes away drops the latch.
57106 func TestLeftFullscreenRemoved(t *testing.T) {
58107 m := coverMonitor(OverflowScroll, true)
@@ -15,6 +15,8 @@ func TestLeftFullscreenRefusesRequest(t *testing.T) {
15 m.OverviewPick(1)15 m.OverviewPick(1)
16 },16 },
17 }17 }
18+ // Fixed overflow refuses an unfocused window's request anyway; scroll
19+ // with a float is where the latch alone refuses it.
18 for _, tc := range pinnedCases {20 for _, tc := range pinnedCases {
19 for name, leave := range leaves {21 for name, leave := range leaves {
20 t.Run(tc.name+"/"+name, func(t *testing.T) {22 t.Run(tc.name+"/"+name, func(t *testing.T) {
@@ -53,6 +55,53 @@ func TestToggleFullscreenOffNoLatch(t *testing.T) {
53 }55 }
54 }56 }
55 57
58+// Focusing the window again drops the latch even without a request, so a
59+// later request while unfocused follows the usual rules again.
60+func TestLeftFullscreenSettlesOnFocus(t *testing.T) {
61+ m := coverMonitor(OverflowScroll, true)
62+ w := m.Current()
63+ w.Activate(1)
64+ w.FocusID(2)
65+ w.settleLeft()
66+ if w.left != 0 {
67+ t.Fatalf("left %d", w.left)
68+ }
69+ w.FocusID(1)
70+ m.SetFullscreen(2, true)
71+ if w.fullscreen != 2 {
72+ t.Fatalf("request refused: %d", w.fullscreen)
73+ }
74+}
75+
76+// A taskbar fullscreen request activates the window first (core), so it
77+// passes the latch.
78+func TestLeftFullscreenTaskbar(t *testing.T) {
79+ m := coverMonitor(OverflowScroll, true)
80+ w := m.Current()
81+ w.Activate(1)
82+ w.Activate(2)
83+ m.SetFullscreen(2, true)
84+ if w.fullscreen != 2 || w.left != 0 {
85+ t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left)
86+ }
87+}
88+
89+// A latched window moved away with its column drops the latch.
90+func TestLeftFullscreenColumnMoved(t *testing.T) {
91+ m := monitor()
92+ m.AddWindow(1)
93+ m.AddWindow(2)
94+ w := m.Current()
95+ m.ToggleFullscreen()
96+ w.Activate(1)
97+ w.FocusID(2)
98+ w.left = 2 // as if moved before the latch settled
99+ m.MoveToWorkspace(1, true)
100+ if w.left != 0 {
101+ t.Fatalf("left %d", w.left)
102+ }
103+}
104+
56 // A latched window that goes away drops the latch.105 // A latched window that goes away drops the latch.
57 func TestLeftFullscreenRemoved(t *testing.T) {106 func TestLeftFullscreenRemoved(t *testing.T) {
58 m := coverMonitor(OverflowScroll, true)107 m := coverMonitor(OverflowScroll, true)
M internal/core/layout.go +13 -1
@@ -818,6 +818,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
818818 if slices.Contains(col.Windows, w.fullscreen) {
819819 w.fullscreen = 0
820820 }
821+ if slices.Contains(col.Windows, w.left) {
822+ w.left = 0
823+ }
821824 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
822825 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
823826 if len(w.Columns) == 0 {
@@ -953,7 +956,8 @@ func (w *Workspace) endFullscreen() {
953956 }
954957
955958 // leaveFullscreen ends the fullscreen for another window the user chose,
956-// and refuses its client's requests until it is focused again (mayCover).
959+// and refuses its client's requests until it is focused again (mayCover,
960+// settleLeft).
957961 func (w *Workspace) leaveFullscreen() {
958962 if w.fullscreen != 0 {
959963 w.left = w.fullscreen
@@ -1080,6 +1084,14 @@ func (w *Workspace) overviewArea() Rect { return w.reserved }
10801084
10811085 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
10821086
1087+// settleLeft drops the latch once its window has the focus again, however
1088+// it got it; core calls it after each event.
1089+func (w *Workspace) settleLeft() {
1090+ if id, ok := w.Focused(); ok && id == w.left {
1091+ w.left = 0
1092+ }
1093+}
1094+
10831095 // mayCover reports whether a client may make id fullscreen. In fixed
10841096 // overflow it would cover the output at once and take the keyboard from
10851097 // the focused window, so only the focused window may; the request of
@@ -818,6 +818,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
818 if slices.Contains(col.Windows, w.fullscreen) {818 if slices.Contains(col.Windows, w.fullscreen) {
819 w.fullscreen = 0819 w.fullscreen = 0
820 }820 }
821+ if slices.Contains(col.Windows, w.left) {
822+ w.left = 0
823+ }
821 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)824 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
822 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))825 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
823 if len(w.Columns) == 0 {826 if len(w.Columns) == 0 {
@@ -953,7 +956,8 @@ func (w *Workspace) endFullscreen() {
953 }956 }
954 957
955 // leaveFullscreen ends the fullscreen for another window the user chose,958 // leaveFullscreen ends the fullscreen for another window the user chose,
956-// and refuses its client's requests until it is focused again (mayCover).959+// and refuses its client's requests until it is focused again (mayCover,
960+// settleLeft).
957 func (w *Workspace) leaveFullscreen() {961 func (w *Workspace) leaveFullscreen() {
958 if w.fullscreen != 0 {962 if w.fullscreen != 0 {
959 w.left = w.fullscreen963 w.left = w.fullscreen
@@ -1080,6 +1084,14 @@ func (w *Workspace) overviewArea() Rect { return w.reserved }
1080 1084
1081 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }1085 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
1082 1086
1087+// settleLeft drops the latch once its window has the focus again, however
1088+// it got it; core calls it after each event.
1089+func (w *Workspace) settleLeft() {
1090+ if id, ok := w.Focused(); ok && id == w.left {
1091+ w.left = 0
1092+ }
1093+}
1094+
1083 // mayCover reports whether a client may make id fullscreen. In fixed1095 // mayCover reports whether a client may make id fullscreen. In fixed
1084 // overflow it would cover the output at once and take the keyboard from1096 // overflow it would cover the output at once and take the keyboard from
1085 // the focused window, so only the focused window may; the request of1097 // the focused window, so only the focused window may; the request of
M internal/core/overview.go +2 -1
@@ -704,7 +704,8 @@ func (w *Workspace) rowHeight() int {
704704 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
705705 // an unscrolled row. span is the row's width; sel is the focused column.
706706 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
707- if c := w.cover(); c != 0 && w.pinned() {
707+ if w.pinned() {
708+ c := w.cover()
708709 // The row shows the columns a pinned fullscreen window hides,
709710 // laid out as without it; its own tile stays marked fullscreen.
710711 v := *w
@@ -704,7 +704,8 @@ func (w *Workspace) rowHeight() int {
704 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses704 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
705 // an unscrolled row. span is the row's width; sel is the focused column.705 // an unscrolled row. span is the row's width; sel is the focused column.
706 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {706 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
707- if c := w.cover(); c != 0 && w.pinned() {707+ if w.pinned() {
708+ c := w.cover()
708 // The row shows the columns a pinned fullscreen window hides,709 // The row shows the columns a pinned fullscreen window hides,
709 // laid out as without it; its own tile stays marked fullscreen.710 // laid out as without it; its own tile stays marked fullscreen.
710 v := *w711 v := *w
M internal/core/overview_stack.go +1 -1
@@ -57,7 +57,7 @@ func (w *Workspace) stack() []stackItem {
5757 case w.overviewMaximized():
5858 items = append(items, stackItem{stackColumn, c})
5959 default:
60- // Its column is the only one.
60+ // One card of all columns, its own marked fullscreen.
6161 items = append(items, stackItem{kind: stackColumns})
6262 columns = false
6363 }
@@ -57,7 +57,7 @@ func (w *Workspace) stack() []stackItem {
57 case w.overviewMaximized():57 case w.overviewMaximized():
58 items = append(items, stackItem{stackColumn, c})58 items = append(items, stackItem{stackColumn, c})
59 default:59 default:
60- // Its column is the only one.60+ // One card of all columns, its own marked fullscreen.
61 items = append(items, stackItem{kind: stackColumns})61 items = append(items, stackItem{kind: stackColumns})
62 columns = false62 columns = false
63 }63 }

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