detached f4eb83f

Merge pull request 'fix(core): reach windows hidden by a fullscreen window from the overview' (#3) from fix/overview-fullscreen-reach into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/3
commit
f4eb83fa39beaf00783aeb7c9b66d6d9ea301ac8
author
bnema · · 2026-10-02 16:01 UTC
merge of
c7c1418 + 052b0e6 · diff against the first parent
signature
unsigned
9 files · +589 -68
M docs/config.md +2 -2
@@ -120,7 +120,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
120120 | `spawn <command>` | Run a program |
121121 | `spawn-terminal` | Run `terminal` |
122122 | `close-window` | Close the focused window |
123-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
123+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
124124 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
125125 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
126126 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -172,7 +172,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
172172
173173 ### Overview
174174
175-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.
175+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
176176
177177 | Key | Action |
178178 | --- | --- |
@@ -120,7 +120,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
120 | `spawn <command>` | Run a program |120 | `spawn <command>` | Run a program |
121 | `spawn-terminal` | Run `terminal` |121 | `spawn-terminal` | Run `terminal` |
122 | `close-window` | Close the focused window |122 | `close-window` | Close the focused window |
123-| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down` or an activation) leaves fullscreen. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |123+| `toggle-fullscreen` | Fullscreen the focused window. An app that asks for fullscreen in its first second (e.g. a Wine launcher that remembered a monitor-sized window) opens in a column instead; later requests are honoured. Fullscreen is exclusive, whether from this bind or the app: nothing is drawn above it but a surface taking the keyboard (a locker). Bars, notifications and other windows, dialogs included, wait hidden until it leaves; a window opening meanwhile waits hidden too. Fullscreen stays in place: the window keeps its column. Focusing another window of the workspace (`focus-column-left/right`, `focus-window-up/down`, an activation or the overview) leaves fullscreen; the app's own fullscreen requests are then refused until it is focused again. In fixed overflow, an app's own request applies only while it has the focus. A taskbar request brings the window on screen with the focus first |
124 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |124 | `toggle-window-stash` | Move the focused tile to the end of the workspace's [stash](#stash), shown and selected; on a stashed window, send it back to its former column, width and maximized or expanded state when possible. A native dialog becomes a new tiled column. A fullscreen window must leave fullscreen first |
125 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |125 | `toggle-floating` | Make the focused tile a [free floating window](#free-floating-windows) at its current size, centred; on a floating window, send it back to its former place in the columns (a native dialog becomes a new column). Does nothing on a stashed or fullscreen window |
126 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |126 | `toggle-stash-visible` | Hide the stash and give the focus back to the tiles, or show it again with the focus on its selected window. Native dialogs stay as they are. Does nothing under a fullscreen window |
@@ -172,7 +172,7 @@ The drop can be on another monitor; focus follows the window. There, the middle
172 172
173 ### Overview173 ### Overview
174 174
175-`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's only preview; `scroll` overflow keeps its columns selectable in one card.175+`toggle-overview` shows scaled previews of the current workspace, including over fullscreen windows. Windows keep their size and previews show their last frames. Covering floats and windows hidden behind a maximized column in `fixed` overflow appear as cards: the on-screen item is in front, hidden columns share one spiral-layout card, up to two cards behind peek above it, and passed cards peek below. Neighbor workspaces show dimmed stacks. Each monitor has one vertical overview. A named workspace appears directly below the numbered workspace from which its `workspace <name>` bind was invoked. Invoking it from another numbered workspace updates this placement; browsing the overview does not. Several named workspaces attached to the same numbered workspace follow configuration order, with horizontal rules separating their group from numbered rows. Named-to-named binds reuse the source's numbered attachment. Unattached named workspaces follow the last occupied numbered row, or the first row if all numbered workspaces are empty. The selected workspace stays centred and its immediate neighbors appear above and below; rows and stash previews keep their full width. Empty workspaces are skipped unless currently selected or serving as the invocation anchor of a visible named row. Named workspaces remain outside the numbered list and normal workspace up/down navigation. Smaller floats, such as dialogs, stay hidden. A fullscreen floating window, or any fullscreen window with `fixed` overflow, is its workspace's front card, with the windows it hides behind it; `scroll` overflow keeps its columns selectable in one card. Choosing the fullscreen window keeps it fullscreen; choosing another window leaves fullscreen.
176 176
177 | Key | Action |177 | Key | Action |
178 | --- | --- |178 | --- | --- |
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+}
A internal/core/fullscreen_left_test.go +133 -0
@@ -0,0 +1,133 @@
1+package core
2+
3+import "testing"
4+
5+// After the user leaves a window's fullscreen for another window, the
6+// client's next fullscreen request is refused until the window is focused
7+// again, whichever way the user left: a focus move, an activation or the
8+// overview.
9+func TestLeftFullscreenRefusesRequest(t *testing.T) {
10+ leaves := map[string]func(m *Monitor){
11+ "focus move": func(m *Monitor) { m.Current().FocusColumn(-1) },
12+ "activate": func(m *Monitor) { m.Current().Activate(1) },
13+ "overview": func(m *Monitor) {
14+ m.ToggleOverview()
15+ m.OverviewPick(1)
16+ },
17+ }
18+ // Fixed overflow refuses an unfocused window's request anyway; scroll
19+ // with a float is where the latch alone refuses it.
20+ for _, tc := range pinnedCases {
21+ for name, leave := range leaves {
22+ t.Run(tc.name+"/"+name, func(t *testing.T) {
23+ m := coverMonitor(tc.overflow, tc.float)
24+ w := m.Current()
25+ leave(m)
26+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || w.left != 2 {
27+ t.Fatalf("leave: focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
28+ }
29+ m.SetFullscreen(2, true)
30+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 {
31+ t.Fatalf("re-request: focused %d, fullscreen %d", f, w.fullscreen)
32+ }
33+ w.FocusID(2)
34+ m.SetFullscreen(2, true)
35+ if w.fullscreen != 2 || w.left != 0 {
36+ t.Fatalf("focused again: fullscreen %d, left %d", w.fullscreen, w.left)
37+ }
38+ })
39+ }
40+ }
41+}
42+
43+// The bind leaves a window's own fullscreen without the latch: the app
44+// may ask again at once.
45+func TestToggleFullscreenOffNoLatch(t *testing.T) {
46+ m := coverMonitor(OverflowFixed, false)
47+ w := m.Current()
48+ m.ToggleFullscreen()
49+ if w.fullscreen != 0 || w.left != 0 {
50+ t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left)
51+ }
52+ m.SetFullscreen(2, true)
53+ if w.fullscreen != 2 {
54+ t.Fatalf("request refused: %d", w.fullscreen)
55+ }
56+}
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, which another window of
90+// it has the focus of, drops the latch.
91+func TestLeftFullscreenColumnMoved(t *testing.T) {
92+ w := cols(OverflowScroll, 2, 0, []WindowID{2, 3}, []WindowID{1})
93+ w.FocusID(2)
94+ w.fullscreen = 2
95+ w.Activate(3)
96+ if w.left != 2 {
97+ t.Fatalf("latch %d", w.left)
98+ }
99+ if _, ok := w.takeColumn(); !ok || w.left != 0 {
100+ t.Fatalf("left %d", w.left)
101+ }
102+}
103+
104+// A latched window that goes away drops the latch.
105+func TestLeftFullscreenRemoved(t *testing.T) {
106+ m := coverMonitor(OverflowScroll, true)
107+ w := m.Current()
108+ w.Activate(1)
109+ w.RemoveWindow(2)
110+ if w.left != 0 {
111+ t.Fatalf("left %d", w.left)
112+ }
113+}
114+
115+// Activating a hidden stashed window leaves another window's fullscreen
116+// with the latch too.
117+func TestActivateHiddenStashLatches(t *testing.T) {
118+ m := newMonitor("", "")
119+ m.SetOutput(300, 200)
120+ m.SetOverflow(OverflowFixed)
121+ w := m.Current()
122+ w.AddWindow(1)
123+ w.AddWindow(5)
124+ w.FocusID(5)
125+ w.ToggleWindowStash()
126+ w.ToggleStashVisible()
127+ w.FocusID(1)
128+ m.ToggleFullscreen()
129+ w.Activate(5)
130+ if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 {
131+ t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
132+ }
133+}
@@ -0,0 +1,133 @@
1+package core
2+
3+import "testing"
4+
5+// After the user leaves a window's fullscreen for another window, the
6+// client's next fullscreen request is refused until the window is focused
7+// again, whichever way the user left: a focus move, an activation or the
8+// overview.
9+func TestLeftFullscreenRefusesRequest(t *testing.T) {
10+ leaves := map[string]func(m *Monitor){
11+ "focus move": func(m *Monitor) { m.Current().FocusColumn(-1) },
12+ "activate": func(m *Monitor) { m.Current().Activate(1) },
13+ "overview": func(m *Monitor) {
14+ m.ToggleOverview()
15+ m.OverviewPick(1)
16+ },
17+ }
18+ // Fixed overflow refuses an unfocused window's request anyway; scroll
19+ // with a float is where the latch alone refuses it.
20+ for _, tc := range pinnedCases {
21+ for name, leave := range leaves {
22+ t.Run(tc.name+"/"+name, func(t *testing.T) {
23+ m := coverMonitor(tc.overflow, tc.float)
24+ w := m.Current()
25+ leave(m)
26+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || w.left != 2 {
27+ t.Fatalf("leave: focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
28+ }
29+ m.SetFullscreen(2, true)
30+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 {
31+ t.Fatalf("re-request: focused %d, fullscreen %d", f, w.fullscreen)
32+ }
33+ w.FocusID(2)
34+ m.SetFullscreen(2, true)
35+ if w.fullscreen != 2 || w.left != 0 {
36+ t.Fatalf("focused again: fullscreen %d, left %d", w.fullscreen, w.left)
37+ }
38+ })
39+ }
40+ }
41+}
42+
43+// The bind leaves a window's own fullscreen without the latch: the app
44+// may ask again at once.
45+func TestToggleFullscreenOffNoLatch(t *testing.T) {
46+ m := coverMonitor(OverflowFixed, false)
47+ w := m.Current()
48+ m.ToggleFullscreen()
49+ if w.fullscreen != 0 || w.left != 0 {
50+ t.Fatalf("fullscreen %d, left %d", w.fullscreen, w.left)
51+ }
52+ m.SetFullscreen(2, true)
53+ if w.fullscreen != 2 {
54+ t.Fatalf("request refused: %d", w.fullscreen)
55+ }
56+}
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, which another window of
90+// it has the focus of, drops the latch.
91+func TestLeftFullscreenColumnMoved(t *testing.T) {
92+ w := cols(OverflowScroll, 2, 0, []WindowID{2, 3}, []WindowID{1})
93+ w.FocusID(2)
94+ w.fullscreen = 2
95+ w.Activate(3)
96+ if w.left != 2 {
97+ t.Fatalf("latch %d", w.left)
98+ }
99+ if _, ok := w.takeColumn(); !ok || w.left != 0 {
100+ t.Fatalf("left %d", w.left)
101+ }
102+}
103+
104+// A latched window that goes away drops the latch.
105+func TestLeftFullscreenRemoved(t *testing.T) {
106+ m := coverMonitor(OverflowScroll, true)
107+ w := m.Current()
108+ w.Activate(1)
109+ w.RemoveWindow(2)
110+ if w.left != 0 {
111+ t.Fatalf("left %d", w.left)
112+ }
113+}
114+
115+// Activating a hidden stashed window leaves another window's fullscreen
116+// with the latch too.
117+func TestActivateHiddenStashLatches(t *testing.T) {
118+ m := newMonitor("", "")
119+ m.SetOutput(300, 200)
120+ m.SetOverflow(OverflowFixed)
121+ w := m.Current()
122+ w.AddWindow(1)
123+ w.AddWindow(5)
124+ w.FocusID(5)
125+ w.ToggleWindowStash()
126+ w.ToggleStashVisible()
127+ w.FocusID(1)
128+ m.ToggleFullscreen()
129+ w.Activate(5)
130+ if f, _ := w.Focused(); f != 5 || w.fullscreen != 0 || w.left != 1 {
131+ t.Fatalf("focused %d, fullscreen %d, left %d", f, w.fullscreen, w.left)
132+ }
133+}
M internal/core/layout.go +43 -6
@@ -171,6 +171,10 @@ type Workspace struct {
171171 border int
172172 presets []Width
173173 fullscreen WindowID
174+ // left is the window whose fullscreen the user left for another
175+ // window: its client requests are refused until it is focused again,
176+ // so an app that re-asks at once (Wine) does not cover that window.
177+ left WindowID
174178 // Floats are native floating windows in user selection order;
175179 // below/above and dialog groups determine their layout order.
176180 Floats []Float
@@ -450,6 +454,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
450454 if w.hiddenFullscreen == id {
451455 w.hiddenFullscreen = 0
452456 }
457+ if w.left == id {
458+ w.left = 0
459+ }
453460 if i := w.floatIndex(id); i >= 0 {
454461 w.Floats = slices.Delete(w.Floats, i, i+1)
455462 if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
@@ -811,6 +818,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
811818 if slices.Contains(col.Windows, w.fullscreen) {
812819 w.fullscreen = 0
813820 }
821+ if slices.Contains(col.Windows, w.left) {
822+ w.left = 0
823+ }
814824 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
815825 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
816826 if len(w.Columns) == 0 {
@@ -945,6 +955,16 @@ func (w *Workspace) endFullscreen() {
945955 w.scroll()
946956 }
947957
958+// leaveFullscreen ends the fullscreen for another window the user chose,
959+// and refuses its client's requests until it is focused again (mayCover,
960+// settleLeft).
961+func (w *Workspace) leaveFullscreen() {
962+ if w.fullscreen != 0 {
963+ w.left = w.fullscreen
964+ }
965+ w.endFullscreen()
966+}
967+
948968 // Activate focuses a window and makes it visible: it leaves another
949969 // window's fullscreen, which hides everything else.
950970 func (w *Workspace) Activate(id WindowID) {
@@ -952,12 +972,12 @@ func (w *Workspace) Activate(id WindowID) {
952972 // A hidden stashed window must be seen: show the stash with it,
953973 // selected. Another window's fullscreen leaves first, so its own
954974 // pending fullscreen comes back.
955- w.endFullscreen()
975+ w.leaveFullscreen()
956976 w.stashAt = i
957977 w.showStash()
958978 }
959979 if w.fullscreen != 0 && w.fullscreen != id {
960- w.endFullscreen()
980+ w.leaveFullscreen()
961981 }
962982 w.FocusID(id)
963983 }
@@ -1064,12 +1084,27 @@ func (w *Workspace) overviewArea() Rect { return w.reserved }
10641084
10651085 func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
10661086
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+
10671095 // mayCover reports whether a client may make id fullscreen. In fixed
10681096 // overflow it would cover the output at once and take the keyboard from
10691097 // the focused window, so only the focused window may; the request of
10701098 // another is refused and the user can still toggle-fullscreen it. A
1071-// hidden stash's request waits for the show (showStash).
1099+// hidden stash's request waits for the show (showStash). A window whose
1100+// fullscreen the user left may only once focused again, in any overflow.
10721101 func (w *Workspace) mayCover(id WindowID) bool {
1102+ if id == w.left {
1103+ if focused, ok := w.Focused(); !ok || focused != id {
1104+ return false
1105+ }
1106+ w.left = 0
1107+ }
10731108 if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {
10741109 return true
10751110 }
@@ -1078,8 +1113,9 @@ func (w *Workspace) mayCover(id WindowID) bool {
10781113 }
10791114
10801115 // pinned reports whether the covering fullscreen window hides every other
1081-// window: focus moves leave it (leaveCover). Only in scroll overflow does
1082-// moving to a tiled neighbor scroll it off and show the target instead.
1116+// window: focus moves leave it (leaveCover), and the overview shows it as
1117+// the front card of its row. Only in scroll overflow does moving to a
1118+// tiled neighbor scroll it off and show the target instead.
10831119 func (w *Workspace) pinned() bool {
10841120 c := w.cover()
10851121 return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
@@ -1095,7 +1131,8 @@ func (w *Workspace) leaveCover(move func()) bool {
10951131 w.fullscreen = 0
10961132 move()
10971133 if id, ok := w.Focused(); ok && id != full {
1098- w.endFullscreen()
1134+ w.fullscreen = full
1135+ w.leaveFullscreen()
10991136 return true
11001137 }
11011138 w.fullscreen = full
@@ -171,6 +171,10 @@ type Workspace struct {
171 border int171 border int
172 presets []Width172 presets []Width
173 fullscreen WindowID173 fullscreen WindowID
174+ // left is the window whose fullscreen the user left for another
175+ // window: its client requests are refused until it is focused again,
176+ // so an app that re-asks at once (Wine) does not cover that window.
177+ left WindowID
174 // Floats are native floating windows in user selection order;178 // Floats are native floating windows in user selection order;
175 // below/above and dialog groups determine their layout order.179 // below/above and dialog groups determine their layout order.
176 Floats []Float180 Floats []Float
@@ -450,6 +454,9 @@ func (w *Workspace) RemoveWindow(id WindowID) {
450 if w.hiddenFullscreen == id {454 if w.hiddenFullscreen == id {
451 w.hiddenFullscreen = 0455 w.hiddenFullscreen = 0
452 }456 }
457+ if w.left == id {
458+ w.left = 0
459+ }
453 if i := w.floatIndex(id); i >= 0 {460 if i := w.floatIndex(id); i >= 0 {
454 w.Floats = slices.Delete(w.Floats, i, i+1)461 w.Floats = slices.Delete(w.Floats, i, i+1)
455 if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {462 if len(w.Floats) == 0 || w.Floats[len(w.Floats)-1].below {
@@ -811,6 +818,9 @@ func (w *Workspace) takeColumn() (Column, bool) {
811 if slices.Contains(col.Windows, w.fullscreen) {818 if slices.Contains(col.Windows, w.fullscreen) {
812 w.fullscreen = 0819 w.fullscreen = 0
813 }820 }
821+ if slices.Contains(col.Windows, w.left) {
822+ w.left = 0
823+ }
814 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)824 w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1)
815 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))825 w.Focus = min(w.Focus, max(len(w.Columns)-1, 0))
816 if len(w.Columns) == 0 {826 if len(w.Columns) == 0 {
@@ -945,6 +955,16 @@ func (w *Workspace) endFullscreen() {
945 w.scroll()955 w.scroll()
946 }956 }
947 957
958+// leaveFullscreen ends the fullscreen for another window the user chose,
959+// and refuses its client's requests until it is focused again (mayCover,
960+// settleLeft).
961+func (w *Workspace) leaveFullscreen() {
962+ if w.fullscreen != 0 {
963+ w.left = w.fullscreen
964+ }
965+ w.endFullscreen()
966+}
967+
948 // Activate focuses a window and makes it visible: it leaves another968 // Activate focuses a window and makes it visible: it leaves another
949 // window's fullscreen, which hides everything else.969 // window's fullscreen, which hides everything else.
950 func (w *Workspace) Activate(id WindowID) {970 func (w *Workspace) Activate(id WindowID) {
@@ -952,12 +972,12 @@ func (w *Workspace) Activate(id WindowID) {
952 // A hidden stashed window must be seen: show the stash with it,972 // A hidden stashed window must be seen: show the stash with it,
953 // selected. Another window's fullscreen leaves first, so its own973 // selected. Another window's fullscreen leaves first, so its own
954 // pending fullscreen comes back.974 // pending fullscreen comes back.
955- w.endFullscreen()975+ w.leaveFullscreen()
956 w.stashAt = i976 w.stashAt = i
957 w.showStash()977 w.showStash()
958 }978 }
959 if w.fullscreen != 0 && w.fullscreen != id {979 if w.fullscreen != 0 && w.fullscreen != id {
960- w.endFullscreen()980+ w.leaveFullscreen()
961 }981 }
962 w.FocusID(id)982 w.FocusID(id)
963 }983 }
@@ -1064,12 +1084,27 @@ func (w *Workspace) overviewArea() Rect { return w.reserved }
1064 1084
1065 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) }
1066 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+
1067 // mayCover reports whether a client may make id fullscreen. In fixed1095 // mayCover reports whether a client may make id fullscreen. In fixed
1068 // 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
1069 // the focused window, so only the focused window may; the request of1097 // the focused window, so only the focused window may; the request of
1070 // another is refused and the user can still toggle-fullscreen it. A1098 // another is refused and the user can still toggle-fullscreen it. A
1071-// hidden stash's request waits for the show (showStash).1099+// hidden stash's request waits for the show (showStash). A window whose
1100+// fullscreen the user left may only once focused again, in any overflow.
1072 func (w *Workspace) mayCover(id WindowID) bool {1101 func (w *Workspace) mayCover(id WindowID) bool {
1102+ if id == w.left {
1103+ if focused, ok := w.Focused(); !ok || focused != id {
1104+ return false
1105+ }
1106+ w.left = 0
1107+ }
1073 if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {1108 if w.Overflow != OverflowFixed || w.stashHidden && w.stashIndex(id) >= 0 {
1074 return true1109 return true
1075 }1110 }
@@ -1078,8 +1113,9 @@ func (w *Workspace) mayCover(id WindowID) bool {
1078 }1113 }
1079 1114
1080 // pinned reports whether the covering fullscreen window hides every other1115 // pinned reports whether the covering fullscreen window hides every other
1081-// window: focus moves leave it (leaveCover). Only in scroll overflow does1116+// window: focus moves leave it (leaveCover), and the overview shows it as
1082-// moving to a tiled neighbor scroll it off and show the target instead.1117+// the front card of its row. Only in scroll overflow does moving to a
1118+// tiled neighbor scroll it off and show the target instead.
1083 func (w *Workspace) pinned() bool {1119 func (w *Workspace) pinned() bool {
1084 c := w.cover()1120 c := w.cover()
1085 return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))1121 return c != 0 && (w.Overflow == OverflowFixed || w.isFloat(c))
@@ -1095,7 +1131,8 @@ func (w *Workspace) leaveCover(move func()) bool {
1095 w.fullscreen = 01131 w.fullscreen = 0
1096 move()1132 move()
1097 if id, ok := w.Focused(); ok && id != full {1133 if id, ok := w.Focused(); ok && id != full {
1098- w.endFullscreen()1134+ w.fullscreen = full
1135+ w.leaveFullscreen()
1099 return true1136 return true
1100 }1137 }
1101 w.fullscreen = full1138 w.fullscreen = full
M internal/core/overview.go +30 -34
@@ -44,7 +44,8 @@ type rowSnapshot struct {
4444 // The overview scales workspace rows without resizing clients. Covering
4545 // floats and the columns form a linear stack of cards; small floats stay
4646 // hidden. Up/down traverse the stack before changing workspace. A pinned
47-// fullscreen window remains the only preview on its row.
47+// fullscreen window is the front card of its row, the windows it hides
48+// behind it; choosing one of them leaves fullscreen.
4849
4950 // A workspace's stash shows as a pile of cards pinned to the left edge of
5051 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -108,9 +109,8 @@ func (m *Monitor) selectRow() {
108109 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
109110 m.ov.selected, m.ov.selectedAt = 0, -1
110111 w := m.Current()
111- if w.pinned() {
112- return
113- }
112+ // A fullscreen window has the focus: its card is in front.
113+ w.focusCover()
114114 if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {
115115 w.floatFocus = false
116116 }
@@ -150,9 +150,6 @@ func (m *Monitor) overviewTarget() WindowID {
150150 return id
151151 }
152152 w := m.Current()
153- if w.pinned() {
154- return w.cover()
155- }
156153 item := m.stackFront(w)
157154 switch item.kind {
158155 case stackFloat:
@@ -211,6 +208,10 @@ func (m *Monitor) overviewMoveTo(a Action) {
211208 if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {
212209 return
213210 }
211+ if cur.pinned() && id != cur.cover() {
212+ // Focused reports the covering window while it covers.
213+ cur.leaveFullscreen()
214+ }
214215 cur.FocusID(id)
215216 m.MoveToWorkspace(i, column)
216217 // The row that received the window keeps it focused on close.
@@ -222,17 +223,22 @@ func (m *Monitor) overviewMoveTo(a Action) {
222223
223224 // closeOverview closes the overview on the selection: a card shows the
224225 // stash on it. A fullscreen request another stashed window made while
225-// the stash was hidden is dropped: the picked card stays in front.
226+// the stash was hidden is dropped: the picked card stays in front. A
227+// window a pinned fullscreen window hides leaves that fullscreen.
226228 func (m *Monitor) closeOverview() {
227229 w := m.Current()
230+ front, target := m.stackFront(w), m.overviewTarget()
231+ if w.pinned() && target != w.cover() {
232+ w.leaveFullscreen()
233+ }
228234 if i := m.cardAt(w); i >= 0 {
229235 if w.hiddenFullscreen != w.Stash[i].ID {
230236 w.hiddenFullscreen = 0
231237 }
232238 w.stashAt = i
233239 w.showStash()
234- } else if !w.pinned() && len(w.stack()) > 0 {
235- w.apply(m.stackFront(w), m.overviewTarget())
240+ } else if front != (stackItem{}) {
241+ w.apply(front, target)
236242 }
237243 for r, s := range m.ov.rows {
238244 if r != w && m.has(r) {
@@ -373,9 +379,6 @@ func (m *Monitor) OverviewMove(dx, dy int) {
373379 m.showOverview(rows[i])
374380 return
375381 }
376- if w.pinned() {
377- return
378- }
379382 if at := m.cardAt(w); at >= 0 {
380383 switch {
381384 case at+dx >= 0 && at+dx < len(w.Stash):
@@ -447,7 +450,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
447450 }
448451 if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {
449452 i := w.floatIndex(id)
450- if !w.coversFloat(w.Floats[i]) && !w.pinned() {
453+ if !w.coversFloat(w.Floats[i]) && !(id == w.cover() && w.pinned()) {
451454 return
452455 }
453456 }
@@ -455,8 +458,6 @@ func (m *Monitor) OverviewPick(id WindowID) {
455458 m.ov.card, m.ov.cardOf = 0, nil
456459 if i := w.stashIndex(id); i >= 0 {
457460 m.selectCard(w, i)
458- } else if w.pinned() {
459- w.FocusID(id)
460461 } else if item, ok := w.itemOf(id); ok {
461462 m.setFront(w, item)
462463 if i := w.columnOf(id); i >= 0 {
@@ -522,16 +523,6 @@ func (m *Monitor) overviewLayout() []Placement {
522523 var result []Placement
523524 for w := range m.all() {
524525 ry, shown := rows[w]
525- if shown && w.pinned() {
526- // Only the covering window shows, as on screen.
527- result = append(result, w.previewRow(ry, w != cur, true)...)
528- for _, id := range w.windows() {
529- if id != w.fullscreen {
530- result = append(result, Placement{ID: id, Hidden: true})
531- }
532- }
533- continue
534- }
535526 if shown {
536527 at := m.cardAt(w)
537528 result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
@@ -622,7 +613,7 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
622613 // pileWidth is the room the stash pile of w takes on the left of its row,
623614 // its margin included; 0 without a stash.
624615 func (w *Workspace) pileWidth() int {
625- if len(w.Stash) == 0 || w.pinned() {
616+ if len(w.Stash) == 0 {
626617 return 0
627618 }
628619 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
@@ -713,15 +704,20 @@ func (w *Workspace) rowHeight() int {
713704 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
714705 // an unscrolled row. span is the row's width; sel is the focused column.
715706 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
716- g := w.gap()
717- h := max(w.Usable.H-2*g, 0)
718707 if w.pinned() {
719- // A covering window nothing else is reachable under (a game) is
720- // the row's only tile, at the size of a fullscreen column. It
721- // stays fullscreen.
722- r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h}
723- return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r
708+ c := w.cover()
709+ // The row shows the columns a pinned fullscreen window hides,
710+ // laid out as without it; its own tile stays marked fullscreen.
711+ v := *w
712+ v.fullscreen = 0
713+ tiles, span, sel = v.previewTiles()
714+ for i := range tiles {
715+ tiles[i].Fullscreen = tiles[i].ID == c
716+ }
717+ return tiles, span, sel
724718 }
719+ g := w.gap()
720+ h := max(w.Usable.H-2*g, 0)
725721 var rects []Rect
726722 if w.Overflow == OverflowFixed {
727723 rects = w.columnRects()
@@ -44,7 +44,8 @@ type rowSnapshot struct {
44 // The overview scales workspace rows without resizing clients. Covering44 // The overview scales workspace rows without resizing clients. Covering
45 // floats and the columns form a linear stack of cards; small floats stay45 // floats and the columns form a linear stack of cards; small floats stay
46 // hidden. Up/down traverse the stack before changing workspace. A pinned46 // hidden. Up/down traverse the stack before changing workspace. A pinned
47-// fullscreen window remains the only preview on its row.47+// fullscreen window is the front card of its row, the windows it hides
48+// behind it; choosing one of them leaves fullscreen.
48 49
49 // A workspace's stash shows as a pile of cards pinned to the left edge of50 // A workspace's stash shows as a pile of cards pinned to the left edge of
50 // its row: the selected stashed window in front at overviewCardZoom, up to51 // its row: the selected stashed window in front at overviewCardZoom, up to
@@ -108,9 +109,8 @@ func (m *Monitor) selectRow() {
108 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0109 m.ov.front, m.ov.row, m.ov.frontAt = stackItem{}, nil, 0
109 m.ov.selected, m.ov.selectedAt = 0, -1110 m.ov.selected, m.ov.selectedAt = 0, -1
110 w := m.Current()111 w := m.Current()
111- if w.pinned() {112+ // A fullscreen window has the focus: its card is in front.
112- return113+ w.focusCover()
113- }
114 if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {114 if w.floatFocus && len(w.Columns) > 0 && (len(w.stack()) < 2 || m.stackFront(w).kind == stackColumns) {
115 w.floatFocus = false115 w.floatFocus = false
116 }116 }
@@ -150,9 +150,6 @@ func (m *Monitor) overviewTarget() WindowID {
150 return id150 return id
151 }151 }
152 w := m.Current()152 w := m.Current()
153- if w.pinned() {
154- return w.cover()
155- }
156 item := m.stackFront(w)153 item := m.stackFront(w)
157 switch item.kind {154 switch item.kind {
158 case stackFloat:155 case stackFloat:
@@ -211,6 +208,10 @@ func (m *Monitor) overviewMoveTo(a Action) {
211 if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {208 if m.card() != 0 || id == 0 || !ok || m.Workspaces[i] == cur {
212 return209 return
213 }210 }
211+ if cur.pinned() && id != cur.cover() {
212+ // Focused reports the covering window while it covers.
213+ cur.leaveFullscreen()
214+ }
214 cur.FocusID(id)215 cur.FocusID(id)
215 m.MoveToWorkspace(i, column)216 m.MoveToWorkspace(i, column)
216 // The row that received the window keeps it focused on close.217 // The row that received the window keeps it focused on close.
@@ -222,17 +223,22 @@ func (m *Monitor) overviewMoveTo(a Action) {
222 223
223 // closeOverview closes the overview on the selection: a card shows the224 // closeOverview closes the overview on the selection: a card shows the
224 // stash on it. A fullscreen request another stashed window made while225 // stash on it. A fullscreen request another stashed window made while
225-// the stash was hidden is dropped: the picked card stays in front.226+// the stash was hidden is dropped: the picked card stays in front. A
227+// window a pinned fullscreen window hides leaves that fullscreen.
226 func (m *Monitor) closeOverview() {228 func (m *Monitor) closeOverview() {
227 w := m.Current()229 w := m.Current()
230+ front, target := m.stackFront(w), m.overviewTarget()
231+ if w.pinned() && target != w.cover() {
232+ w.leaveFullscreen()
233+ }
228 if i := m.cardAt(w); i >= 0 {234 if i := m.cardAt(w); i >= 0 {
229 if w.hiddenFullscreen != w.Stash[i].ID {235 if w.hiddenFullscreen != w.Stash[i].ID {
230 w.hiddenFullscreen = 0236 w.hiddenFullscreen = 0
231 }237 }
232 w.stashAt = i238 w.stashAt = i
233 w.showStash()239 w.showStash()
234- } else if !w.pinned() && len(w.stack()) > 0 {240+ } else if front != (stackItem{}) {
235- w.apply(m.stackFront(w), m.overviewTarget())241+ w.apply(front, target)
236 }242 }
237 for r, s := range m.ov.rows {243 for r, s := range m.ov.rows {
238 if r != w && m.has(r) {244 if r != w && m.has(r) {
@@ -373,9 +379,6 @@ func (m *Monitor) OverviewMove(dx, dy int) {
373 m.showOverview(rows[i])379 m.showOverview(rows[i])
374 return380 return
375 }381 }
376- if w.pinned() {
377- return
378- }
379 if at := m.cardAt(w); at >= 0 {382 if at := m.cardAt(w); at >= 0 {
380 switch {383 switch {
381 case at+dx >= 0 && at+dx < len(w.Stash):384 case at+dx >= 0 && at+dx < len(w.Stash):
@@ -447,7 +450,7 @@ func (m *Monitor) OverviewPick(id WindowID) {
447 }450 }
448 if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {451 if w.floatIndex(id) >= 0 && w.stashIndex(id) < 0 {
449 i := w.floatIndex(id)452 i := w.floatIndex(id)
450- if !w.coversFloat(w.Floats[i]) && !w.pinned() {453+ if !w.coversFloat(w.Floats[i]) && !(id == w.cover() && w.pinned()) {
451 return454 return
452 }455 }
453 }456 }
@@ -455,8 +458,6 @@ func (m *Monitor) OverviewPick(id WindowID) {
455 m.ov.card, m.ov.cardOf = 0, nil458 m.ov.card, m.ov.cardOf = 0, nil
456 if i := w.stashIndex(id); i >= 0 {459 if i := w.stashIndex(id); i >= 0 {
457 m.selectCard(w, i)460 m.selectCard(w, i)
458- } else if w.pinned() {
459- w.FocusID(id)
460 } else if item, ok := w.itemOf(id); ok {461 } else if item, ok := w.itemOf(id); ok {
461 m.setFront(w, item)462 m.setFront(w, item)
462 if i := w.columnOf(id); i >= 0 {463 if i := w.columnOf(id); i >= 0 {
@@ -522,16 +523,6 @@ func (m *Monitor) overviewLayout() []Placement {
522 var result []Placement523 var result []Placement
523 for w := range m.all() {524 for w := range m.all() {
524 ry, shown := rows[w]525 ry, shown := rows[w]
525- if shown && w.pinned() {
526- // Only the covering window shows, as on screen.
527- result = append(result, w.previewRow(ry, w != cur, true)...)
528- for _, id := range w.windows() {
529- if id != w.fullscreen {
530- result = append(result, Placement{ID: id, Hidden: true})
531- }
532- }
533- continue
534- }
535 if shown {526 if shown {
536 at := m.cardAt(w)527 at := m.cardAt(w)
537 result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)528 result = append(result, m.stackRow(w, ry, w != cur, at < 0)...)
@@ -622,7 +613,7 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
622 // pileWidth is the room the stash pile of w takes on the left of its row,613 // pileWidth is the room the stash pile of w takes on the left of its row,
623 // its margin included; 0 without a stash.614 // its margin included; 0 without a stash.
624 func (w *Workspace) pileWidth() int {615 func (w *Workspace) pileWidth() int {
625- if len(w.Stash) == 0 || w.pinned() {616+ if len(w.Stash) == 0 {
626 return 0617 return 0
627 }618 }
628 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))619 card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
@@ -713,15 +704,20 @@ func (w *Workspace) rowHeight() int {
713 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses704 // corner. Fixed overflow uses its on-screen geometry; scroll overflow uses
714 // 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.
715 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {706 func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
716- g := w.gap()
717- h := max(w.Usable.H-2*g, 0)
718 if w.pinned() {707 if w.pinned() {
719- // A covering window nothing else is reachable under (a game) is708+ c := w.cover()
720- // the row's only tile, at the size of a fullscreen column. It709+ // The row shows the columns a pinned fullscreen window hides,
721- // stays fullscreen.710+ // laid out as without it; its own tile stays marked fullscreen.
722- r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: h}711+ v := *w
723- return []Placement{{ID: w.cover(), Rect: r, Focused: true, Fullscreen: true}}, r.X + r.W + g, r712+ v.fullscreen = 0
713+ tiles, span, sel = v.previewTiles()
714+ for i := range tiles {
715+ tiles[i].Fullscreen = tiles[i].ID == c
716+ }
717+ return tiles, span, sel
724 }718 }
719+ g := w.gap()
720+ h := max(w.Usable.H-2*g, 0)
725 var rects []Rect721 var rects []Rect
726 if w.Overflow == OverflowFixed {722 if w.Overflow == OverflowFixed {
727 rects = w.columnRects()723 rects = w.columnRects()
A internal/core/overview_cover_test.go +228 -0
@@ -0,0 +1,228 @@
1+package core
2+
3+import "testing"
4+
5+// coverMonitor has, on a 300x200 output, tiles 1 and 2 on one workspace,
6+// 2 fullscreen. With float, 2 is a floating game over tile 1 instead.
7+func coverMonitor(o Overflow, float bool) *Monitor {
8+ m := newMonitor("", "")
9+ m.SetOutput(300, 200)
10+ m.SetOverflow(o)
11+ m.SetMaxColumns(2)
12+ m.AddWindow(1)
13+ if float {
14+ m.AddFloating(2, 300, 200)
15+ m.SetFullscreen(2, true)
16+ } else {
17+ m.AddWindow(2)
18+ m.ToggleFullscreen()
19+ }
20+ return m
21+}
22+
23+// pinnedCases are the setups where the fullscreen window pins its row.
24+var pinnedCases = []struct {
25+ name string
26+ overflow Overflow
27+ float bool
28+}{
29+ {"fixed tile", OverflowFixed, false},
30+ {"fixed float", OverflowFixed, true},
31+ {"scroll float", OverflowScroll, true},
32+}
33+
34+// uniquePlacements fails when a window is placed twice.
35+func uniquePlacements(t *testing.T, ps []Placement) {
36+ t.Helper()
37+ seen := map[WindowID]bool{}
38+ for _, p := range ps {
39+ if seen[p.ID] {
40+ t.Fatalf("window %d placed twice: %+v", p.ID, ps)
41+ }
42+ seen[p.ID] = true
43+ }
44+}
45+
46+// The overview over a pinned fullscreen window shows it in front, still
47+// fullscreen and selected, and the window it hides behind it.
48+func TestOverviewCoverShowsWorkspace(t *testing.T) {
49+ for _, tc := range pinnedCases {
50+ t.Run(tc.name, func(t *testing.T) {
51+ m := coverMonitor(tc.overflow, tc.float)
52+ if !m.Current().pinned() {
53+ t.Fatal("not pinned")
54+ }
55+ m.ToggleOverview()
56+ ps := m.Layout()
57+ uniquePlacements(t, ps)
58+ game, behind := previewOf(t, ps, 2), previewOf(t, ps, 1)
59+ if game.Hidden || !game.Focused || game.Peek || !game.Fullscreen {
60+ t.Fatalf("game %+v", game)
61+ }
62+ if behind.Hidden || !behind.Peek || behind.Focused {
63+ t.Fatalf("behind %+v", behind)
64+ }
65+ })
66+ }
67+}
68+
69+// Return or a click on the fullscreen window keeps it fullscreen.
70+func TestOverviewCoverConfirmKeeps(t *testing.T) {
71+ for _, tc := range pinnedCases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ for _, pick := range []bool{false, true} {
74+ m := coverMonitor(tc.overflow, tc.float)
75+ m.ToggleOverview()
76+ if pick {
77+ m.OverviewPick(2)
78+ } else {
79+ m.ToggleOverview()
80+ }
81+ if f, _ := m.Focused(); f != 2 || m.ov.open || m.Current().fullscreen != 2 || !previewOf(t, m.Layout(), 2).Fullscreen {
82+ t.Fatalf("pick %v: focused %d, fullscreen %d", pick, f, m.Current().fullscreen)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// Choosing the window behind leaves fullscreen and focuses it; the left
90+// window shows again, a float behind the tiles.
91+func TestOverviewCoverPickOther(t *testing.T) {
92+ for _, tc := range pinnedCases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ for _, pick := range []bool{false, true} {
95+ m := coverMonitor(tc.overflow, tc.float)
96+ m.ToggleOverview()
97+ if pick {
98+ m.OverviewPick(1)
99+ } else {
100+ m.OverviewMove(-1, 0)
101+ m.ToggleOverview()
102+ }
103+ w := m.Current()
104+ if f, _ := m.Focused(); f != 1 || m.ov.open || w.fullscreen != 0 {
105+ t.Fatalf("pick %v: focused %d, fullscreen %d", pick, f, w.fullscreen)
106+ }
107+ if p := placement(w, 2); p.Fullscreen || (p.Hidden && !tc.float) {
108+ t.Fatalf("pick %v: left window %+v", pick, p)
109+ }
110+ if i := w.floatIndex(2); tc.float && (i < 0 || !w.Floats[i].below) {
111+ t.Fatalf("pick %v: float %+v", pick, w.Floats)
112+ }
113+ }
114+ })
115+ }
116+}
117+
118+// Escape returns to the fullscreen window, still fullscreen.
119+func TestOverviewCoverCancel(t *testing.T) {
120+ for _, tc := range pinnedCases {
121+ t.Run(tc.name, func(t *testing.T) {
122+ m := coverMonitor(tc.overflow, tc.float)
123+ m.ToggleOverview()
124+ m.OverviewMove(-1, 0)
125+ m.CancelOverview()
126+ if f, _ := m.Focused(); f != 2 || m.Current().fullscreen != 2 {
127+ t.Fatalf("focused %d, fullscreen %d", f, m.Current().fullscreen)
128+ }
129+ })
130+ }
131+}
132+
133+// A stash card is reachable under a fullscreen tile; choosing it leaves
134+// fullscreen.
135+func TestOverviewCoverPickStashCard(t *testing.T) {
136+ m := newMonitor("", "")
137+ m.SetOutput(300, 200)
138+ m.SetOverflow(OverflowFixed)
139+ w := m.Current()
140+ w.AddWindow(1)
141+ w.AddWindow(5)
142+ w.FocusID(5)
143+ w.ToggleWindowStash()
144+ w.FocusID(1)
145+ m.ToggleFullscreen()
146+ if !w.pinned() {
147+ t.Fatal("not pinned")
148+ }
149+ m.ToggleOverview()
150+ uniquePlacements(t, m.Layout())
151+ m.OverviewMove(-1, 0)
152+ if m.card() != 5 {
153+ t.Fatalf("card %d", m.card())
154+ }
155+ m.ToggleOverview()
156+ if f, _ := m.Focused(); f != 5 || w.fullscreen != 0 {
157+ t.Fatalf("focused %d, fullscreen %d", f, w.fullscreen)
158+ }
159+}
160+
161+// Window binds act on the selected card: a window behind the fullscreen
162+// one moves or closes, not the fullscreen window.
163+func TestOverviewCoverActsOnSelection(t *testing.T) {
164+ for _, tc := range pinnedCases {
165+ t.Run(tc.name, func(t *testing.T) {
166+ m := coverMonitor(tc.overflow, tc.float)
167+ m.ToggleOverview()
168+ if e := m.Apply(ActionCloseWindow); e.Close != 2 {
169+ t.Fatalf("front close %d", e.Close)
170+ }
171+ m.OverviewMove(-1, 0)
172+ if e := m.Apply(ActionCloseWindow); e.Close != 1 {
173+ t.Fatalf("behind close %d", e.Close)
174+ }
175+ w := m.Current()
176+ m.Apply(ActionMoveWindowToWorkspaceDown)
177+ if got, _ := m.find(1); got == w || !w.has(2) {
178+ t.Fatalf("moved the wrong window: %v", windows(m))
179+ }
180+ })
181+ }
182+}
183+
184+// A fullscreen tile whose column is also maximized hands the maximized
185+// state to the column picked behind it, as a maximized row does.
186+func TestOverviewCoverMaximizedPick(t *testing.T) {
187+ m := newMonitor("", "")
188+ m.SetOutput(300, 200)
189+ m.SetOverflow(OverflowFixed)
190+ m.AddWindow(1)
191+ m.AddWindow(2)
192+ w := m.Current()
193+ w.ToggleFullWidth()
194+ m.ToggleFullscreen()
195+ m.ToggleOverview()
196+ m.OverviewPick(1)
197+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || !w.Columns[0].FullWidth || w.Columns[1].FullWidth {
198+ t.Fatalf("focused %d, fullscreen %d, columns %+v", f, w.fullscreen, w.Columns)
199+ }
200+}
201+
202+// A fixed fullscreen tile among several columns is the front card, the
203+// other columns behind it, as for a maximized column.
204+func TestOverviewCoverTileAmongColumns(t *testing.T) {
205+ m := newMonitor("", "")
206+ m.SetOutput(300, 200)
207+ m.SetOverflow(OverflowFixed)
208+ m.AddWindow(1)
209+ m.AddWindow(2)
210+ m.AddWindow(3)
211+ m.Current().FocusID(2)
212+ m.ToggleFullscreen()
213+ m.ToggleOverview()
214+ ps := m.Layout()
215+ uniquePlacements(t, ps)
216+ if g := previewOf(t, ps, 2); g.Hidden || g.Peek || !g.Fullscreen {
217+ t.Fatalf("game %+v", g)
218+ }
219+ for _, id := range []WindowID{1, 3} {
220+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
221+ t.Fatalf("%d: %+v", id, p)
222+ }
223+ }
224+ m.OverviewPick(3)
225+ if f, _ := m.Focused(); f != 3 || m.Current().fullscreen != 0 {
226+ t.Fatalf("focused %d, fullscreen %d", f, m.Current().fullscreen)
227+ }
228+}
@@ -0,0 +1,228 @@
1+package core
2+
3+import "testing"
4+
5+// coverMonitor has, on a 300x200 output, tiles 1 and 2 on one workspace,
6+// 2 fullscreen. With float, 2 is a floating game over tile 1 instead.
7+func coverMonitor(o Overflow, float bool) *Monitor {
8+ m := newMonitor("", "")
9+ m.SetOutput(300, 200)
10+ m.SetOverflow(o)
11+ m.SetMaxColumns(2)
12+ m.AddWindow(1)
13+ if float {
14+ m.AddFloating(2, 300, 200)
15+ m.SetFullscreen(2, true)
16+ } else {
17+ m.AddWindow(2)
18+ m.ToggleFullscreen()
19+ }
20+ return m
21+}
22+
23+// pinnedCases are the setups where the fullscreen window pins its row.
24+var pinnedCases = []struct {
25+ name string
26+ overflow Overflow
27+ float bool
28+}{
29+ {"fixed tile", OverflowFixed, false},
30+ {"fixed float", OverflowFixed, true},
31+ {"scroll float", OverflowScroll, true},
32+}
33+
34+// uniquePlacements fails when a window is placed twice.
35+func uniquePlacements(t *testing.T, ps []Placement) {
36+ t.Helper()
37+ seen := map[WindowID]bool{}
38+ for _, p := range ps {
39+ if seen[p.ID] {
40+ t.Fatalf("window %d placed twice: %+v", p.ID, ps)
41+ }
42+ seen[p.ID] = true
43+ }
44+}
45+
46+// The overview over a pinned fullscreen window shows it in front, still
47+// fullscreen and selected, and the window it hides behind it.
48+func TestOverviewCoverShowsWorkspace(t *testing.T) {
49+ for _, tc := range pinnedCases {
50+ t.Run(tc.name, func(t *testing.T) {
51+ m := coverMonitor(tc.overflow, tc.float)
52+ if !m.Current().pinned() {
53+ t.Fatal("not pinned")
54+ }
55+ m.ToggleOverview()
56+ ps := m.Layout()
57+ uniquePlacements(t, ps)
58+ game, behind := previewOf(t, ps, 2), previewOf(t, ps, 1)
59+ if game.Hidden || !game.Focused || game.Peek || !game.Fullscreen {
60+ t.Fatalf("game %+v", game)
61+ }
62+ if behind.Hidden || !behind.Peek || behind.Focused {
63+ t.Fatalf("behind %+v", behind)
64+ }
65+ })
66+ }
67+}
68+
69+// Return or a click on the fullscreen window keeps it fullscreen.
70+func TestOverviewCoverConfirmKeeps(t *testing.T) {
71+ for _, tc := range pinnedCases {
72+ t.Run(tc.name, func(t *testing.T) {
73+ for _, pick := range []bool{false, true} {
74+ m := coverMonitor(tc.overflow, tc.float)
75+ m.ToggleOverview()
76+ if pick {
77+ m.OverviewPick(2)
78+ } else {
79+ m.ToggleOverview()
80+ }
81+ if f, _ := m.Focused(); f != 2 || m.ov.open || m.Current().fullscreen != 2 || !previewOf(t, m.Layout(), 2).Fullscreen {
82+ t.Fatalf("pick %v: focused %d, fullscreen %d", pick, f, m.Current().fullscreen)
83+ }
84+ }
85+ })
86+ }
87+}
88+
89+// Choosing the window behind leaves fullscreen and focuses it; the left
90+// window shows again, a float behind the tiles.
91+func TestOverviewCoverPickOther(t *testing.T) {
92+ for _, tc := range pinnedCases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ for _, pick := range []bool{false, true} {
95+ m := coverMonitor(tc.overflow, tc.float)
96+ m.ToggleOverview()
97+ if pick {
98+ m.OverviewPick(1)
99+ } else {
100+ m.OverviewMove(-1, 0)
101+ m.ToggleOverview()
102+ }
103+ w := m.Current()
104+ if f, _ := m.Focused(); f != 1 || m.ov.open || w.fullscreen != 0 {
105+ t.Fatalf("pick %v: focused %d, fullscreen %d", pick, f, w.fullscreen)
106+ }
107+ if p := placement(w, 2); p.Fullscreen || (p.Hidden && !tc.float) {
108+ t.Fatalf("pick %v: left window %+v", pick, p)
109+ }
110+ if i := w.floatIndex(2); tc.float && (i < 0 || !w.Floats[i].below) {
111+ t.Fatalf("pick %v: float %+v", pick, w.Floats)
112+ }
113+ }
114+ })
115+ }
116+}
117+
118+// Escape returns to the fullscreen window, still fullscreen.
119+func TestOverviewCoverCancel(t *testing.T) {
120+ for _, tc := range pinnedCases {
121+ t.Run(tc.name, func(t *testing.T) {
122+ m := coverMonitor(tc.overflow, tc.float)
123+ m.ToggleOverview()
124+ m.OverviewMove(-1, 0)
125+ m.CancelOverview()
126+ if f, _ := m.Focused(); f != 2 || m.Current().fullscreen != 2 {
127+ t.Fatalf("focused %d, fullscreen %d", f, m.Current().fullscreen)
128+ }
129+ })
130+ }
131+}
132+
133+// A stash card is reachable under a fullscreen tile; choosing it leaves
134+// fullscreen.
135+func TestOverviewCoverPickStashCard(t *testing.T) {
136+ m := newMonitor("", "")
137+ m.SetOutput(300, 200)
138+ m.SetOverflow(OverflowFixed)
139+ w := m.Current()
140+ w.AddWindow(1)
141+ w.AddWindow(5)
142+ w.FocusID(5)
143+ w.ToggleWindowStash()
144+ w.FocusID(1)
145+ m.ToggleFullscreen()
146+ if !w.pinned() {
147+ t.Fatal("not pinned")
148+ }
149+ m.ToggleOverview()
150+ uniquePlacements(t, m.Layout())
151+ m.OverviewMove(-1, 0)
152+ if m.card() != 5 {
153+ t.Fatalf("card %d", m.card())
154+ }
155+ m.ToggleOverview()
156+ if f, _ := m.Focused(); f != 5 || w.fullscreen != 0 {
157+ t.Fatalf("focused %d, fullscreen %d", f, w.fullscreen)
158+ }
159+}
160+
161+// Window binds act on the selected card: a window behind the fullscreen
162+// one moves or closes, not the fullscreen window.
163+func TestOverviewCoverActsOnSelection(t *testing.T) {
164+ for _, tc := range pinnedCases {
165+ t.Run(tc.name, func(t *testing.T) {
166+ m := coverMonitor(tc.overflow, tc.float)
167+ m.ToggleOverview()
168+ if e := m.Apply(ActionCloseWindow); e.Close != 2 {
169+ t.Fatalf("front close %d", e.Close)
170+ }
171+ m.OverviewMove(-1, 0)
172+ if e := m.Apply(ActionCloseWindow); e.Close != 1 {
173+ t.Fatalf("behind close %d", e.Close)
174+ }
175+ w := m.Current()
176+ m.Apply(ActionMoveWindowToWorkspaceDown)
177+ if got, _ := m.find(1); got == w || !w.has(2) {
178+ t.Fatalf("moved the wrong window: %v", windows(m))
179+ }
180+ })
181+ }
182+}
183+
184+// A fullscreen tile whose column is also maximized hands the maximized
185+// state to the column picked behind it, as a maximized row does.
186+func TestOverviewCoverMaximizedPick(t *testing.T) {
187+ m := newMonitor("", "")
188+ m.SetOutput(300, 200)
189+ m.SetOverflow(OverflowFixed)
190+ m.AddWindow(1)
191+ m.AddWindow(2)
192+ w := m.Current()
193+ w.ToggleFullWidth()
194+ m.ToggleFullscreen()
195+ m.ToggleOverview()
196+ m.OverviewPick(1)
197+ if f, _ := m.Focused(); f != 1 || w.fullscreen != 0 || !w.Columns[0].FullWidth || w.Columns[1].FullWidth {
198+ t.Fatalf("focused %d, fullscreen %d, columns %+v", f, w.fullscreen, w.Columns)
199+ }
200+}
201+
202+// A fixed fullscreen tile among several columns is the front card, the
203+// other columns behind it, as for a maximized column.
204+func TestOverviewCoverTileAmongColumns(t *testing.T) {
205+ m := newMonitor("", "")
206+ m.SetOutput(300, 200)
207+ m.SetOverflow(OverflowFixed)
208+ m.AddWindow(1)
209+ m.AddWindow(2)
210+ m.AddWindow(3)
211+ m.Current().FocusID(2)
212+ m.ToggleFullscreen()
213+ m.ToggleOverview()
214+ ps := m.Layout()
215+ uniquePlacements(t, ps)
216+ if g := previewOf(t, ps, 2); g.Hidden || g.Peek || !g.Fullscreen {
217+ t.Fatalf("game %+v", g)
218+ }
219+ for _, id := range []WindowID{1, 3} {
220+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
221+ t.Fatalf("%d: %+v", id, p)
222+ }
223+ }
224+ m.OverviewPick(3)
225+ if f, _ := m.Focused(); f != 3 || m.Current().fullscreen != 0 {
226+ t.Fatalf("focused %d, fullscreen %d", f, m.Current().fullscreen)
227+ }
228+}
M internal/core/overview_stack.go +35 -6
@@ -18,8 +18,17 @@ type stackItem struct {
1818 id WindowID
1919 }
2020
21+// overviewMaximized reports a fixed row whose focused column hides the
22+// others: maximized, or holding the covering fullscreen tile. Its card
23+// shows in front of the hidden columns' card.
2124 func (w *Workspace) overviewMaximized() bool {
22- return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned()
25+ if w.Overflow != OverflowFixed || len(w.Columns) < 2 || w.Focus < 0 || w.Focus >= len(w.Columns) {
26+ return false
27+ }
28+ if c := w.cover(); c != 0 && !w.isFloat(c) {
29+ return w.columnOf(c) == w.Focus
30+ }
31+ return w.Columns[w.Focus].FullWidth
2332 }
2433
2534 func (w *Workspace) columnOf(id WindowID) int {
@@ -32,21 +41,36 @@ func (w *Workspace) columnOf(id WindowID) int {
3241 }
3342
3443 // stack lists what is on screen followed by what it hides, top to bottom.
44+// A pinned fullscreen window is the front card, a float on its own, a
45+// fixed tile with its column, so every window it hides stays reachable; a
46+// stashed one is its card in the pile.
3547 func (w *Workspace) stack() []stackItem {
48+ var items []stackItem
49+ c, columns := WindowID(0), len(w.Columns) > 0
3650 if w.pinned() {
37- return nil
51+ c = w.cover()
52+ }
53+ switch {
54+ case c == 0, w.stashIndex(c) >= 0:
55+ case w.floatIndex(c) >= 0:
56+ items = append(items, stackItem{stackFloat, c})
57+ case w.overviewMaximized():
58+ items = append(items, stackItem{stackColumn, c})
59+ default:
60+ // One card of all columns, its own marked fullscreen.
61+ items = append(items, stackItem{kind: stackColumns})
62+ columns = false
3863 }
39- var items []stackItem
4064 for i := len(w.Floats) - 1; i >= 0; i-- {
4165 f := w.Floats[i]
4266 if !f.below && w.coversFloat(f) {
4367 items = append(items, stackItem{stackFloat, f.ID})
4468 }
4569 }
46- if w.overviewMaximized() {
70+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {
4771 items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
4872 }
49- if len(w.Columns) > 0 {
73+ if columns {
5074 items = append(items, stackItem{kind: stackColumns})
5175 }
5276 for i := len(w.Floats) - 1; i >= 0; i-- {
@@ -242,6 +266,11 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
242266 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)
243267 }
244268 g := w.gap()
269+ if item.kind == stackFloat && item.id == w.cover() {
270+ // A fullscreen float shows at the size of a fullscreen column.
271+ r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
272+ return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
273+ }
245274 if item.kind == stackFloat {
246275 f := w.Floats[w.floatIndex(item.id)]
247276 r := w.floatRect(f)
@@ -259,7 +288,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
259288 r.Y = (w.Usable.H - r.H) / 2
260289 tiles := make([]Placement, 0, len(c.Windows))
261290 for j, t := range rowRects(r, c, g) {
262- tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})
291+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
263292 }
264293 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
265294 }
@@ -18,8 +18,17 @@ type stackItem struct {
18 id WindowID18 id WindowID
19 }19 }
20 20
21+// overviewMaximized reports a fixed row whose focused column hides the
22+// others: maximized, or holding the covering fullscreen tile. Its card
23+// shows in front of the hidden columns' card.
21 func (w *Workspace) overviewMaximized() bool {24 func (w *Workspace) overviewMaximized() bool {
22- return w.Overflow == OverflowFixed && len(w.Columns) > 1 && w.Focus >= 0 && w.Focus < len(w.Columns) && w.Columns[w.Focus].FullWidth && !w.pinned()25+ if w.Overflow != OverflowFixed || len(w.Columns) < 2 || w.Focus < 0 || w.Focus >= len(w.Columns) {
26+ return false
27+ }
28+ if c := w.cover(); c != 0 && !w.isFloat(c) {
29+ return w.columnOf(c) == w.Focus
30+ }
31+ return w.Columns[w.Focus].FullWidth
23 }32 }
24 33
25 func (w *Workspace) columnOf(id WindowID) int {34 func (w *Workspace) columnOf(id WindowID) int {
@@ -32,21 +41,36 @@ func (w *Workspace) columnOf(id WindowID) int {
32 }41 }
33 42
34 // stack lists what is on screen followed by what it hides, top to bottom.43 // stack lists what is on screen followed by what it hides, top to bottom.
44+// A pinned fullscreen window is the front card, a float on its own, a
45+// fixed tile with its column, so every window it hides stays reachable; a
46+// stashed one is its card in the pile.
35 func (w *Workspace) stack() []stackItem {47 func (w *Workspace) stack() []stackItem {
48+ var items []stackItem
49+ c, columns := WindowID(0), len(w.Columns) > 0
36 if w.pinned() {50 if w.pinned() {
37- return nil51+ c = w.cover()
52+ }
53+ switch {
54+ case c == 0, w.stashIndex(c) >= 0:
55+ case w.floatIndex(c) >= 0:
56+ items = append(items, stackItem{stackFloat, c})
57+ case w.overviewMaximized():
58+ items = append(items, stackItem{stackColumn, c})
59+ default:
60+ // One card of all columns, its own marked fullscreen.
61+ items = append(items, stackItem{kind: stackColumns})
62+ columns = false
38 }63 }
39- var items []stackItem
40 for i := len(w.Floats) - 1; i >= 0; i-- {64 for i := len(w.Floats) - 1; i >= 0; i-- {
41 f := w.Floats[i]65 f := w.Floats[i]
42 if !f.below && w.coversFloat(f) {66 if !f.below && w.coversFloat(f) {
43 items = append(items, stackItem{stackFloat, f.ID})67 items = append(items, stackItem{stackFloat, f.ID})
44 }68 }
45 }69 }
46- if w.overviewMaximized() {70+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {
47 items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})71 items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
48 }72 }
49- if len(w.Columns) > 0 {73+ if columns {
50 items = append(items, stackItem{kind: stackColumns})74 items = append(items, stackItem{kind: stackColumns})
51 }75 }
52 for i := len(w.Floats) - 1; i >= 0; i-- {76 for i := len(w.Floats) - 1; i >= 0; i-- {
@@ -242,6 +266,11 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
242 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)266 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, sel)
243 }267 }
244 g := w.gap()268 g := w.gap()
269+ if item.kind == stackFloat && item.id == w.cover() {
270+ // A fullscreen float shows at the size of a fullscreen column.
271+ r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
272+ return w.previewRowTiles(y, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
273+ }
245 if item.kind == stackFloat {274 if item.kind == stackFloat {
246 f := w.Floats[w.floatIndex(item.id)]275 f := w.Floats[w.floatIndex(item.id)]
247 r := w.floatRect(f)276 r := w.floatRect(f)
@@ -259,7 +288,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
259 r.Y = (w.Usable.H - r.H) / 2288 r.Y = (w.Usable.H - r.H) / 2
260 tiles := make([]Placement, 0, len(c.Windows))289 tiles := make([]Placement, 0, len(c.Windows))
261 for j, t := range rowRects(r, c, g) {290 for j, t := range rowRects(r, c, g) {
262- tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})291+ tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
263 }292 }
264 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)293 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
265 }294 }
M internal/core/overview_test.go +32 -20
@@ -208,26 +208,35 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
208208 }
209209 }
210210
211-// A tiled fullscreen window (fixed overflow) is its row's only preview
212-// too: the selection cannot leave it for a hidden column, and Return keeps
213-// it fullscreen.
211+// A tiled fullscreen window (fixed overflow) is its row's front card:
212+// Return keeps it fullscreen; the column it hides is behind, and picking
213+// it leaves fullscreen.
214214 func TestOverviewOpensOverFullscreenTile(t *testing.T) {
215- m := newMonitor("", "")
216- m.SetOutput(300, 200)
217- m.SetOverflow(OverflowFixed)
218- m.AddWindow(1)
219- m.AddWindow(2)
220- m.ToggleFullscreen()
221- m.ToggleOverview()
215+ open := func() *Monitor {
216+ m := newMonitor("", "")
217+ m.SetOutput(300, 200)
218+ m.SetOverflow(OverflowFixed)
219+ m.AddWindow(1)
220+ m.AddWindow(2)
221+ m.ToggleFullscreen()
222+ m.ToggleOverview()
223+ return m
224+ }
225+ m := open()
222226 game := previewOf(t, m.Layout(), 2)
223227 if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {
224228 t.Fatalf("game %+v", game)
225229 }
226- m.OverviewMove(-1, 0)
227230 m.ToggleOverview()
228231 if f, _ := m.Focused(); f != 2 || m.Active != 0 || !previewOf(t, m.Layout(), 2).Fullscreen {
229232 t.Fatalf("confirm: %d on %d", f, m.Active)
230233 }
234+ m = open()
235+ m.OverviewMove(-1, 0)
236+ m.ToggleOverview()
237+ if f, _ := m.Focused(); f != 1 || m.Current().fullscreen != 0 {
238+ t.Fatalf("behind: focused %d, fullscreen %d", f, m.Current().fullscreen)
239+ }
231240 }
232241
233242 // Under scroll overflow a fullscreen column does not pin the row: another
@@ -250,9 +259,9 @@ func TestOverviewOverScrollFullscreenPicksColumn(t *testing.T) {
250259 }
251260 }
252261
253-// Only the game shows on its row: its dialogs and the columns under it
254-// stay hidden, as on screen.
255-func TestOverviewFullscreenFloatHidesOthers(t *testing.T) {
262+// A neighbor row with a fullscreen game shows it in front, the columns it
263+// hides behind; a dialog under it stays hidden, as for any small float.
264+func TestOverviewFullscreenFloatRow(t *testing.T) {
256265 m := gameMonitor(OverflowScroll)
257266 m.Focus(1)
258267 m.AddWindow(5)
@@ -262,14 +271,17 @@ func TestOverviewFullscreenFloatHidesOthers(t *testing.T) {
262271 m.Focus(0)
263272 m.ToggleOverview()
264273 ps := m.Layout()
265- if previewOf(t, ps, 2).Hidden {
266- t.Fatal("game hidden")
267- }
268- for _, id := range []WindowID{3, 4, 5} {
269- if p := previewOf(t, ps, id); !p.Hidden {
270- t.Fatalf("%d shown: %+v", id, p)
274+ for _, id := range []WindowID{2, 3, 5} {
275+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
276+ t.Fatalf("%d: %+v", id, p)
271277 }
272278 }
279+ if p := previewOf(t, ps, 4); !p.Hidden {
280+ t.Fatalf("dialog shown: %+v", p)
281+ }
282+ if g := previewOf(t, ps, 2); !g.Fullscreen {
283+ t.Fatalf("game %+v", g)
284+ }
273285 }
274286
275287 // pileMonitor is overviewMonitor with 5, 6 and 7 stashed on workspace 1
@@ -208,26 +208,35 @@ func TestOverviewOpensOverFullscreenFloat(t *testing.T) {
208 }208 }
209 }209 }
210 210
211-// A tiled fullscreen window (fixed overflow) is its row's only preview211+// A tiled fullscreen window (fixed overflow) is its row's front card:
212-// too: the selection cannot leave it for a hidden column, and Return keeps212+// Return keeps it fullscreen; the column it hides is behind, and picking
213-// it fullscreen.213+// it leaves fullscreen.
214 func TestOverviewOpensOverFullscreenTile(t *testing.T) {214 func TestOverviewOpensOverFullscreenTile(t *testing.T) {
215- m := newMonitor("", "")215+ open := func() *Monitor {
216- m.SetOutput(300, 200)216+ m := newMonitor("", "")
217- m.SetOverflow(OverflowFixed)217+ m.SetOutput(300, 200)
218- m.AddWindow(1)218+ m.SetOverflow(OverflowFixed)
219- m.AddWindow(2)219+ m.AddWindow(1)
220- m.ToggleFullscreen()220+ m.AddWindow(2)
221- m.ToggleOverview()221+ m.ToggleFullscreen()
222+ m.ToggleOverview()
223+ return m
224+ }
225+ m := open()
222 game := previewOf(t, m.Layout(), 2)226 game := previewOf(t, m.Layout(), 2)
223 if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {227 if !m.ov.open || !game.Focused || game.Peek || !game.Fullscreen {
224 t.Fatalf("game %+v", game)228 t.Fatalf("game %+v", game)
225 }229 }
226- m.OverviewMove(-1, 0)
227 m.ToggleOverview()230 m.ToggleOverview()
228 if f, _ := m.Focused(); f != 2 || m.Active != 0 || !previewOf(t, m.Layout(), 2).Fullscreen {231 if f, _ := m.Focused(); f != 2 || m.Active != 0 || !previewOf(t, m.Layout(), 2).Fullscreen {
229 t.Fatalf("confirm: %d on %d", f, m.Active)232 t.Fatalf("confirm: %d on %d", f, m.Active)
230 }233 }
234+ m = open()
235+ m.OverviewMove(-1, 0)
236+ m.ToggleOverview()
237+ if f, _ := m.Focused(); f != 1 || m.Current().fullscreen != 0 {
238+ t.Fatalf("behind: focused %d, fullscreen %d", f, m.Current().fullscreen)
239+ }
231 }240 }
232 241
233 // Under scroll overflow a fullscreen column does not pin the row: another242 // Under scroll overflow a fullscreen column does not pin the row: another
@@ -250,9 +259,9 @@ func TestOverviewOverScrollFullscreenPicksColumn(t *testing.T) {
250 }259 }
251 }260 }
252 261
253-// Only the game shows on its row: its dialogs and the columns under it262+// A neighbor row with a fullscreen game shows it in front, the columns it
254-// stay hidden, as on screen.263+// hides behind; a dialog under it stays hidden, as for any small float.
255-func TestOverviewFullscreenFloatHidesOthers(t *testing.T) {264+func TestOverviewFullscreenFloatRow(t *testing.T) {
256 m := gameMonitor(OverflowScroll)265 m := gameMonitor(OverflowScroll)
257 m.Focus(1)266 m.Focus(1)
258 m.AddWindow(5)267 m.AddWindow(5)
@@ -262,14 +271,17 @@ func TestOverviewFullscreenFloatHidesOthers(t *testing.T) {
262 m.Focus(0)271 m.Focus(0)
263 m.ToggleOverview()272 m.ToggleOverview()
264 ps := m.Layout()273 ps := m.Layout()
265- if previewOf(t, ps, 2).Hidden {274+ for _, id := range []WindowID{2, 3, 5} {
266- t.Fatal("game hidden")275+ if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
267- }276+ t.Fatalf("%d: %+v", id, p)
268- for _, id := range []WindowID{3, 4, 5} {
269- if p := previewOf(t, ps, id); !p.Hidden {
270- t.Fatalf("%d shown: %+v", id, p)
271 }277 }
272 }278 }
279+ if p := previewOf(t, ps, 4); !p.Hidden {
280+ t.Fatalf("dialog shown: %+v", p)
281+ }
282+ if g := previewOf(t, ps, 2); !g.Fullscreen {
283+ t.Fatalf("game %+v", g)
284+ }
273 }285 }
274 286
275 // pileMonitor is overviewMonitor with 5, 6 and 7 stashed on workspace 1287 // pileMonitor is overviewMonitor with 5, 6 and 7 stashed on workspace 1

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