detached 0b45e97

merge: fullscreen-ws (fixed-overflow fullscreen on its own workspace)

commit
0b45e97f5d25fe667bc033ea0614f78ea44d97c4
author
brice · · 2026-09-25 20:24 UTC
merge of
1ee32a2 + bfb04cb · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +529 -11
M internal/core/action.go +5 -0
@@ -175,6 +175,9 @@ func (m *Monitor) Apply(a Action) Effect {
175175 m.Focus(m.Active + dir)
176176 }
177177 return Effect{}
178+ case ActionToggleFullscreen:
179+ m.ToggleFullscreen()
180+ return Effect{}
178181 case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:
179182 if m.shown == nil && a == ActionFocusWorkspaceUp {
180183 m.Focus(m.Active - 1)
@@ -219,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect {
219222 case ActionCycleColumnWidth:
220223 w.CycleWidth()
221224 case ActionToggleFullscreen:
225+ // In place only: binds go through Monitor.Apply, which gives a
226+ // fixed-overflow fullscreen its own workspace.
222227 w.ToggleFullscreen()
223228 case ActionCloseWindow:
224229 id, _ := w.Focused()
@@ -175,6 +175,9 @@ func (m *Monitor) Apply(a Action) Effect {
175 m.Focus(m.Active + dir)175 m.Focus(m.Active + dir)
176 }176 }
177 return Effect{}177 return Effect{}
178+ case ActionToggleFullscreen:
179+ m.ToggleFullscreen()
180+ return Effect{}
178 case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:181 case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown:
179 if m.shown == nil && a == ActionFocusWorkspaceUp {182 if m.shown == nil && a == ActionFocusWorkspaceUp {
180 m.Focus(m.Active - 1)183 m.Focus(m.Active - 1)
@@ -219,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect {
219 case ActionCycleColumnWidth:222 case ActionCycleColumnWidth:
220 w.CycleWidth()223 w.CycleWidth()
221 case ActionToggleFullscreen:224 case ActionToggleFullscreen:
225+ // In place only: binds go through Monitor.Apply, which gives a
226+ // fixed-overflow fullscreen its own workspace.
222 w.ToggleFullscreen()227 w.ToggleFullscreen()
223 case ActionCloseWindow:228 case ActionCloseWindow:
224 id, _ := w.Focused()229 id, _ := w.Focused()
M internal/core/layout.go +19 -0
@@ -136,6 +136,25 @@ type Workspace struct {
136136 homePos int
137137 // termAt is when core last spawned a terminal for this workspace.
138138 termAt time.Time
139+ // origin is set on the workspace a fixed-overflow fullscreen window
140+ // moved to; back is where it returns there (Monitor.fullscreenHome).
141+ origin *Workspace
142+ back origPlace
143+}
144+
145+// origPlace remembers where a window was: its column, its row in a stacked
146+// column, the column's width and slot, or its floating size.
147+type origPlace struct {
148+ // id is the window that went fullscreen; tiled lists the windows that
149+ // joined its workspace tiled, floating there until they go home.
150+ id WindowID
151+ tiled []WindowID
152+ col, row, slot int
153+ // stacked holds the windows left in the column: the window returns to
154+ // its row in the column that still holds one of them.
155+ stacked []WindowID
156+ width Width
157+ float *Float
139158 }
140159
141160 // Float is a floating window and its client size, logical.
@@ -136,6 +136,25 @@ type Workspace struct {
136 homePos int136 homePos int
137 // termAt is when core last spawned a terminal for this workspace.137 // termAt is when core last spawned a terminal for this workspace.
138 termAt time.Time138 termAt time.Time
139+ // origin is set on the workspace a fixed-overflow fullscreen window
140+ // moved to; back is where it returns there (Monitor.fullscreenHome).
141+ origin *Workspace
142+ back origPlace
143+}
144+
145+// origPlace remembers where a window was: its column, its row in a stacked
146+// column, the column's width and slot, or its floating size.
147+type origPlace struct {
148+ // id is the window that went fullscreen; tiled lists the windows that
149+ // joined its workspace tiled, floating there until they go home.
150+ id WindowID
151+ tiled []WindowID
152+ col, row, slot int
153+ // stacked holds the windows left in the column: the window returns to
154+ // its row in the column that still holds one of them.
155+ stacked []WindowID
156+ width Width
157+ float *Float
139 }158 }
140 159
141 // Float is a floating window and its client size, logical.160 // Float is a floating window and its client size, logical.
M internal/core/monitor.go +240 -8
@@ -117,7 +117,7 @@ func (m *Monitor) Current() *Workspace {
117117 func (m *Monitor) newWorkspace() *Workspace {
118118 w := m.template
119119 w.presets = append([]Width(nil), m.template.presets...)
120- w.Columns, w.Floats, w.floatFocus, w.home = nil, nil, false, ""
120+ w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
121121 return &w
122122 }
123123
@@ -125,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace {
125125 // unnamed ones that are neither the first nor active. The active workspace
126126 // never changes.
127127 func (m *Monitor) normalize() {
128+ m.fullscreenHome()
128129 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
129130 for i := len(m.Workspaces) - 2; i >= 1; i-- {
130131 if spare(i) && i != m.Active {
@@ -222,11 +223,18 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
222223 return nil, -1
223224 }
224225
225-// AddWindow places a new window on the workspace on screen.
226+// AddWindow places a new window on the workspace on screen. On a
227+// fullscreen workspace it floats, above the fullscreen window: a tiled one
228+// would be hidden behind it (fixed overflow) while it has the focus.
226229 func (m *Monitor) AddWindow(id WindowID) {
227230 if w, _ := m.find(id); w != nil {
228231 return
229232 }
233+ if cur := m.Current(); cur.origin != nil {
234+ cur.joinFullscreen(id)
235+ m.normalize()
236+ return
237+ }
230238 m.Current().AddWindow(id)
231239 m.normalize()
232240 }
@@ -240,20 +248,233 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
240248 m.normalize()
241249 }
242250
243-// RemoveWindow drops the window wherever it is; focus stays on the active workspace.
251+// RemoveWindow drops the window wherever it is; focus stays on the active
252+// workspace, except that closing a fullscreen workspace goes back to its origin.
244253 func (m *Monitor) RemoveWindow(id WindowID) {
245254 if w, _ := m.find(id); w != nil {
246- w.RemoveWindow(id)
255+ if w.origin != nil && id == w.back.id && m.has(w.origin) {
256+ // Its dialogs go home with the view.
257+ origin, back := w.origin, w.back
258+ w.RemoveWindow(id)
259+ w.origin, w.back = nil, origPlace{}
260+ m.foldInto(w, origin, back.col, false, back.tiled)
261+ } else {
262+ w.RemoveWindow(id)
263+ }
247264 m.normalize()
248265 }
249266 }
250267
251-// SetFullscreen applies a client request on the window's own workspace. It does
252-// not switch workspaces: client requests never move the user.
268+// SetFullscreen applies a client request. Under fixed overflow the window
269+// gets its own workspace, as with the bind. A client request never moves
270+// the user (ADR 011): the view follows only a window that is focused and
271+// on screen, and a window coming back does not take the focus.
253272 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
254- if w, _ := m.find(id); w != nil {
273+ w, _ := m.find(id)
274+ switch {
275+ case w == nil:
276+ case w.origin != nil && id != w.back.id:
277+ // A dialog of a fullscreen workspace already floats above it; its
278+ // own fullscreen would take the workspace from the window it holds.
279+ case on && m.ownWorkspace(w, id):
280+ focused, _ := w.Focused()
281+ m.enterFullscreen(w, id, w == m.Current() && focused == id)
282+ case !on && w.origin != nil && w.fullscreen == id:
283+ // The user was on that window: they stay on it, back home.
284+ m.leaveFullscreen(w, m.Current() == w)
285+ default:
255286 w.SetFullscreen(id, on)
256287 }
288+ m.normalize()
289+}
290+
291+// ToggleFullscreen is the bind. Under fixed overflow the focused window
292+// moves to a new workspace below the current one, so workspace up/down
293+// still reach the others; toggling again sends it back where it was.
294+func (m *Monitor) ToggleFullscreen() {
295+ w := m.Current()
296+ id, ok := w.Focused()
297+ switch {
298+ case !ok:
299+ case w.origin != nil && w.fullscreen == w.back.id:
300+ // From its window or one of its dialogs.
301+ m.leaveFullscreen(w, true)
302+ case m.ownWorkspace(w, id):
303+ m.enterFullscreen(w, id, true)
304+ default:
305+ w.ToggleFullscreen()
306+ }
307+ m.normalize()
308+}
309+
310+// ownWorkspace reports whether going fullscreen moves the window to its own
311+// workspace: fixed overflow hides every other window behind a fullscreen
312+// one and never scrolls, so it would leave the user stuck. Not needed when
313+// the window is alone, or already has its own workspace.
314+func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
315+ return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w)
316+}
317+
318+// enterFullscreen moves window id from w to a new workspace just below the
319+// numbered one on screen, fullscreen, and shows it when show is set. It
320+// remembers the window's place in w to return there.
321+func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
322+ fs := m.newWorkspace()
323+ fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
324+ fs.origin = w
325+ fs.back.id = id
326+ if f := w.floatIndex(id); f >= 0 {
327+ fl := w.Floats[f]
328+ fs.back.float = &fl
329+ w.RemoveWindow(id)
330+ fs.AddFloating(id, fl.W, fl.H)
331+ } else {
332+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
333+ c := w.Columns[col]
334+ fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
335+ for _, v := range c.Windows {
336+ if v != id {
337+ fs.back.stacked = append(fs.back.stacked, v)
338+ }
339+ }
340+ w.RemoveWindow(id)
341+ fs.AddWindow(id)
342+ fs.Columns[0].Width = c.Width
343+ }
344+ fs.SetFullscreen(id, true)
345+ at := m.Active + 1
346+ if i := indexOf(m.Workspaces, w); i >= 0 {
347+ at = i + 1
348+ }
349+ m.Workspaces = slices.Insert(m.Workspaces, at, fs)
350+ if at <= m.Active {
351+ m.Active++
352+ }
353+ if show {
354+ m.show(fs)
355+ }
356+}
357+
358+// leaveFullscreen sends the window of fs back to its place in the origin,
359+// with the windows opened there meanwhile (its dialogs) just after it, and
360+// drops fs; the view goes along when fs was on screen. focus is set when
361+// the user was on the window: it gets the focus back there.
362+func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
363+ if m.fullscreenHome(); fs.origin == nil {
364+ fs.SetFullscreen(fs.fullscreen, false)
365+ return
366+ }
367+ origin, back, id := fs.origin, fs.back, fs.back.id
368+ fs.origin, fs.back = nil, origPlace{}
369+ fs.RemoveWindow(id)
370+ // A closure: back.col is where the window lands, known after the switch.
371+ defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }()
372+ switch prev := origin.Focus; {
373+ case back.float != nil && focus:
374+ origin.AddFloating(id, back.float.W, back.float.H)
375+ case back.float != nil:
376+ // At the bottom: the top float, maybe focused, stays on top.
377+ origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
378+ case slices.ContainsFunc(origin.Columns, back.holdsStack):
379+ back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
380+ c := &origin.Columns[back.col]
381+ // Below the windows that were above it and are still there.
382+ row := 0
383+ for _, v := range back.stacked[:back.row] {
384+ if slices.Contains(c.Windows, v) {
385+ row++
386+ }
387+ }
388+ row = min(row, len(c.Windows))
389+ c.Windows = slices.Insert(c.Windows, row, id)
390+ if focus {
391+ origin.Focus, c.Focus, origin.floatFocus = back.col, row, false
392+ } else if row <= c.Focus && len(c.Windows) > 1 {
393+ c.Focus++
394+ }
395+ default:
396+ at := min(back.col, len(origin.Columns))
397+ origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot})
398+ if focus {
399+ origin.floatFocus = false
400+ } else if len(origin.Columns) > 1 {
401+ origin.Focus = prev
402+ if at <= prev {
403+ origin.Focus++
404+ }
405+ }
406+ }
407+}
408+
409+// foldInto moves what is left in fs (windows opened while fullscreen: its
410+// dialogs) to origin, tiled ones from column at, floating ones on top, and
411+// drops fs. The view goes along when fs was on screen.
412+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) {
413+ shown := m.Current() == fs
414+ at = min(max(at, 0), len(origin.Columns))
415+ for _, c := range slices.Backward(fs.Columns) {
416+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus})
417+ if at <= origin.Focus && len(origin.Columns) > 1 {
418+ origin.Focus++
419+ }
420+ }
421+ var floats []Float
422+ for _, f := range fs.Floats {
423+ if !slices.Contains(tiled, f.ID) {
424+ floats = append(floats, f)
425+ continue
426+ }
427+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}})
428+ if at <= origin.Focus && len(origin.Columns) > 1 {
429+ origin.Focus++
430+ }
431+ at++
432+ }
433+ if focus || shown {
434+ origin.Floats = append(origin.Floats, floats...)
435+ origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus
436+ } else {
437+ // The user is elsewhere: below, the focused float keeps the focus.
438+ origin.Floats = append(floats, origin.Floats...)
439+ }
440+ fs.Columns, fs.Floats = nil, nil
441+ origin.scroll()
442+ m.take(fs)
443+ if shown {
444+ m.show(origin)
445+ }
446+}
447+
448+// awayInSlot reports whether id is away from slot n of w, fullscreen in a
449+// workspace that returns it there.
450+func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
451+ for _, fs := range m.all() {
452+ if fs.origin == w && fs.back.id == id && fs.back.slot == n {
453+ return true
454+ }
455+ }
456+ return false
457+}
458+
459+// holdsStack reports whether c holds a window of the stack left behind.
460+func (p origPlace) holdsStack(c Column) bool {
461+ return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) })
462+}
463+
464+// fullscreenHome keeps each fullscreen workspace linked to its origin only
465+// while its window is still there and fullscreen, and the origin is still
466+// on this monitor. Otherwise (the window moved out or activated away from
467+// fullscreen, the origin gone to another monitor) it is a normal workspace.
468+// Other windows opened there (a dialog) stay when the window returns.
469+func (m *Monitor) fullscreenHome() {
470+ for _, w := range m.all() {
471+ if w.origin == nil {
472+ continue
473+ }
474+ if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) {
475+ w.origin, w.back = nil, origPlace{}
476+ }
477+ }
257478 }
258479
259480 // MoveToWorkspace moves the focused window to numbered workspace index i;
@@ -269,13 +490,24 @@ func (m *Monitor) MoveToWorkspace(i int) {
269490 fl := cur.Floats[f]
270491 cur.RemoveWindow(id)
271492 m.Workspaces[i].AddFloating(id, fl.W, fl.H)
493+ } else if to := m.Workspaces[i]; to.origin != nil {
494+ cur.RemoveWindow(id)
495+ to.joinFullscreen(id)
272496 } else {
273497 cur.RemoveWindow(id)
274- m.Workspaces[i].AddWindow(id)
498+ to.AddWindow(id)
275499 }
276500 m.normalize()
277501 }
278502
503+// joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
504+// above the fullscreen window, which would hide it (fixed overflow), and
505+// tiles again once home.
506+func (fs *Workspace) joinFullscreen(id WindowID) {
507+ fs.AddFloating(id, 0, 0)
508+ fs.back.tiled = append(fs.back.tiled, id)
509+}
510+
279511 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
280512
281513 // Layout places every window: the workspace on screen laid out, others hidden.
@@ -117,7 +117,7 @@ func (m *Monitor) Current() *Workspace {
117 func (m *Monitor) newWorkspace() *Workspace {117 func (m *Monitor) newWorkspace() *Workspace {
118 w := m.template118 w := m.template
119 w.presets = append([]Width(nil), m.template.presets...)119 w.presets = append([]Width(nil), m.template.presets...)
120- w.Columns, w.Floats, w.floatFocus, w.home = nil, nil, false, ""120+ w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
121 return &w121 return &w
122 }122 }
123 123
@@ -125,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace {
125 // unnamed ones that are neither the first nor active. The active workspace125 // unnamed ones that are neither the first nor active. The active workspace
126 // never changes.126 // never changes.
127 func (m *Monitor) normalize() {127 func (m *Monitor) normalize() {
128+ m.fullscreenHome()
128 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }129 spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" }
129 for i := len(m.Workspaces) - 2; i >= 1; i-- {130 for i := len(m.Workspaces) - 2; i >= 1; i-- {
130 if spare(i) && i != m.Active {131 if spare(i) && i != m.Active {
@@ -222,11 +223,18 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
222 return nil, -1223 return nil, -1
223 }224 }
224 225
225-// AddWindow places a new window on the workspace on screen.226+// AddWindow places a new window on the workspace on screen. On a
227+// fullscreen workspace it floats, above the fullscreen window: a tiled one
228+// would be hidden behind it (fixed overflow) while it has the focus.
226 func (m *Monitor) AddWindow(id WindowID) {229 func (m *Monitor) AddWindow(id WindowID) {
227 if w, _ := m.find(id); w != nil {230 if w, _ := m.find(id); w != nil {
228 return231 return
229 }232 }
233+ if cur := m.Current(); cur.origin != nil {
234+ cur.joinFullscreen(id)
235+ m.normalize()
236+ return
237+ }
230 m.Current().AddWindow(id)238 m.Current().AddWindow(id)
231 m.normalize()239 m.normalize()
232 }240 }
@@ -240,20 +248,233 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
240 m.normalize()248 m.normalize()
241 }249 }
242 250
243-// RemoveWindow drops the window wherever it is; focus stays on the active workspace.251+// RemoveWindow drops the window wherever it is; focus stays on the active
252+// workspace, except that closing a fullscreen workspace goes back to its origin.
244 func (m *Monitor) RemoveWindow(id WindowID) {253 func (m *Monitor) RemoveWindow(id WindowID) {
245 if w, _ := m.find(id); w != nil {254 if w, _ := m.find(id); w != nil {
246- w.RemoveWindow(id)255+ if w.origin != nil && id == w.back.id && m.has(w.origin) {
256+ // Its dialogs go home with the view.
257+ origin, back := w.origin, w.back
258+ w.RemoveWindow(id)
259+ w.origin, w.back = nil, origPlace{}
260+ m.foldInto(w, origin, back.col, false, back.tiled)
261+ } else {
262+ w.RemoveWindow(id)
263+ }
247 m.normalize()264 m.normalize()
248 }265 }
249 }266 }
250 267
251-// SetFullscreen applies a client request on the window's own workspace. It does268+// SetFullscreen applies a client request. Under fixed overflow the window
252-// not switch workspaces: client requests never move the user.269+// gets its own workspace, as with the bind. A client request never moves
270+// the user (ADR 011): the view follows only a window that is focused and
271+// on screen, and a window coming back does not take the focus.
253 func (m *Monitor) SetFullscreen(id WindowID, on bool) {272 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
254- if w, _ := m.find(id); w != nil {273+ w, _ := m.find(id)
274+ switch {
275+ case w == nil:
276+ case w.origin != nil && id != w.back.id:
277+ // A dialog of a fullscreen workspace already floats above it; its
278+ // own fullscreen would take the workspace from the window it holds.
279+ case on && m.ownWorkspace(w, id):
280+ focused, _ := w.Focused()
281+ m.enterFullscreen(w, id, w == m.Current() && focused == id)
282+ case !on && w.origin != nil && w.fullscreen == id:
283+ // The user was on that window: they stay on it, back home.
284+ m.leaveFullscreen(w, m.Current() == w)
285+ default:
255 w.SetFullscreen(id, on)286 w.SetFullscreen(id, on)
256 }287 }
288+ m.normalize()
289+}
290+
291+// ToggleFullscreen is the bind. Under fixed overflow the focused window
292+// moves to a new workspace below the current one, so workspace up/down
293+// still reach the others; toggling again sends it back where it was.
294+func (m *Monitor) ToggleFullscreen() {
295+ w := m.Current()
296+ id, ok := w.Focused()
297+ switch {
298+ case !ok:
299+ case w.origin != nil && w.fullscreen == w.back.id:
300+ // From its window or one of its dialogs.
301+ m.leaveFullscreen(w, true)
302+ case m.ownWorkspace(w, id):
303+ m.enterFullscreen(w, id, true)
304+ default:
305+ w.ToggleFullscreen()
306+ }
307+ m.normalize()
308+}
309+
310+// ownWorkspace reports whether going fullscreen moves the window to its own
311+// workspace: fixed overflow hides every other window behind a fullscreen
312+// one and never scrolls, so it would leave the user stuck. Not needed when
313+// the window is alone, or already has its own workspace.
314+func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
315+ return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w)
316+}
317+
318+// enterFullscreen moves window id from w to a new workspace just below the
319+// numbered one on screen, fullscreen, and shows it when show is set. It
320+// remembers the window's place in w to return there.
321+func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
322+ fs := m.newWorkspace()
323+ fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
324+ fs.origin = w
325+ fs.back.id = id
326+ if f := w.floatIndex(id); f >= 0 {
327+ fl := w.Floats[f]
328+ fs.back.float = &fl
329+ w.RemoveWindow(id)
330+ fs.AddFloating(id, fl.W, fl.H)
331+ } else {
332+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
333+ c := w.Columns[col]
334+ fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
335+ for _, v := range c.Windows {
336+ if v != id {
337+ fs.back.stacked = append(fs.back.stacked, v)
338+ }
339+ }
340+ w.RemoveWindow(id)
341+ fs.AddWindow(id)
342+ fs.Columns[0].Width = c.Width
343+ }
344+ fs.SetFullscreen(id, true)
345+ at := m.Active + 1
346+ if i := indexOf(m.Workspaces, w); i >= 0 {
347+ at = i + 1
348+ }
349+ m.Workspaces = slices.Insert(m.Workspaces, at, fs)
350+ if at <= m.Active {
351+ m.Active++
352+ }
353+ if show {
354+ m.show(fs)
355+ }
356+}
357+
358+// leaveFullscreen sends the window of fs back to its place in the origin,
359+// with the windows opened there meanwhile (its dialogs) just after it, and
360+// drops fs; the view goes along when fs was on screen. focus is set when
361+// the user was on the window: it gets the focus back there.
362+func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
363+ if m.fullscreenHome(); fs.origin == nil {
364+ fs.SetFullscreen(fs.fullscreen, false)
365+ return
366+ }
367+ origin, back, id := fs.origin, fs.back, fs.back.id
368+ fs.origin, fs.back = nil, origPlace{}
369+ fs.RemoveWindow(id)
370+ // A closure: back.col is where the window lands, known after the switch.
371+ defer func() { m.foldInto(fs, origin, back.col+1, focus, back.tiled) }()
372+ switch prev := origin.Focus; {
373+ case back.float != nil && focus:
374+ origin.AddFloating(id, back.float.W, back.float.H)
375+ case back.float != nil:
376+ // At the bottom: the top float, maybe focused, stays on top.
377+ origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
378+ case slices.ContainsFunc(origin.Columns, back.holdsStack):
379+ back.col = slices.IndexFunc(origin.Columns, back.holdsStack)
380+ c := &origin.Columns[back.col]
381+ // Below the windows that were above it and are still there.
382+ row := 0
383+ for _, v := range back.stacked[:back.row] {
384+ if slices.Contains(c.Windows, v) {
385+ row++
386+ }
387+ }
388+ row = min(row, len(c.Windows))
389+ c.Windows = slices.Insert(c.Windows, row, id)
390+ if focus {
391+ origin.Focus, c.Focus, origin.floatFocus = back.col, row, false
392+ } else if row <= c.Focus && len(c.Windows) > 1 {
393+ c.Focus++
394+ }
395+ default:
396+ at := min(back.col, len(origin.Columns))
397+ origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot})
398+ if focus {
399+ origin.floatFocus = false
400+ } else if len(origin.Columns) > 1 {
401+ origin.Focus = prev
402+ if at <= prev {
403+ origin.Focus++
404+ }
405+ }
406+ }
407+}
408+
409+// foldInto moves what is left in fs (windows opened while fullscreen: its
410+// dialogs) to origin, tiled ones from column at, floating ones on top, and
411+// drops fs. The view goes along when fs was on screen.
412+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) {
413+ shown := m.Current() == fs
414+ at = min(max(at, 0), len(origin.Columns))
415+ for _, c := range slices.Backward(fs.Columns) {
416+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus})
417+ if at <= origin.Focus && len(origin.Columns) > 1 {
418+ origin.Focus++
419+ }
420+ }
421+ var floats []Float
422+ for _, f := range fs.Floats {
423+ if !slices.Contains(tiled, f.ID) {
424+ floats = append(floats, f)
425+ continue
426+ }
427+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}})
428+ if at <= origin.Focus && len(origin.Columns) > 1 {
429+ origin.Focus++
430+ }
431+ at++
432+ }
433+ if focus || shown {
434+ origin.Floats = append(origin.Floats, floats...)
435+ origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus
436+ } else {
437+ // The user is elsewhere: below, the focused float keeps the focus.
438+ origin.Floats = append(floats, origin.Floats...)
439+ }
440+ fs.Columns, fs.Floats = nil, nil
441+ origin.scroll()
442+ m.take(fs)
443+ if shown {
444+ m.show(origin)
445+ }
446+}
447+
448+// awayInSlot reports whether id is away from slot n of w, fullscreen in a
449+// workspace that returns it there.
450+func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
451+ for _, fs := range m.all() {
452+ if fs.origin == w && fs.back.id == id && fs.back.slot == n {
453+ return true
454+ }
455+ }
456+ return false
457+}
458+
459+// holdsStack reports whether c holds a window of the stack left behind.
460+func (p origPlace) holdsStack(c Column) bool {
461+ return slices.ContainsFunc(c.Windows, func(v WindowID) bool { return slices.Contains(p.stacked, v) })
462+}
463+
464+// fullscreenHome keeps each fullscreen workspace linked to its origin only
465+// while its window is still there and fullscreen, and the origin is still
466+// on this monitor. Otherwise (the window moved out or activated away from
467+// fullscreen, the origin gone to another monitor) it is a normal workspace.
468+// Other windows opened there (a dialog) stay when the window returns.
469+func (m *Monitor) fullscreenHome() {
470+ for _, w := range m.all() {
471+ if w.origin == nil {
472+ continue
473+ }
474+ if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) {
475+ w.origin, w.back = nil, origPlace{}
476+ }
477+ }
257 }478 }
258 479
259 // MoveToWorkspace moves the focused window to numbered workspace index i;480 // MoveToWorkspace moves the focused window to numbered workspace index i;
@@ -269,13 +490,24 @@ func (m *Monitor) MoveToWorkspace(i int) {
269 fl := cur.Floats[f]490 fl := cur.Floats[f]
270 cur.RemoveWindow(id)491 cur.RemoveWindow(id)
271 m.Workspaces[i].AddFloating(id, fl.W, fl.H)492 m.Workspaces[i].AddFloating(id, fl.W, fl.H)
493+ } else if to := m.Workspaces[i]; to.origin != nil {
494+ cur.RemoveWindow(id)
495+ to.joinFullscreen(id)
272 } else {496 } else {
273 cur.RemoveWindow(id)497 cur.RemoveWindow(id)
274- m.Workspaces[i].AddWindow(id)498+ to.AddWindow(id)
275 }499 }
276 m.normalize()500 m.normalize()
277 }501 }
278 502
503+// joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
504+// above the fullscreen window, which would hide it (fixed overflow), and
505+// tiles again once home.
506+func (fs *Workspace) joinFullscreen(id WindowID) {
507+ fs.AddFloating(id, 0, 0)
508+ fs.back.tiled = append(fs.back.tiled, id)
509+}
510+
279 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }511 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
280 512
281 // Layout places every window: the workspace on screen laid out, others hidden.513 // Layout places every window: the workspace on screen laid out, others hidden.
M internal/core/monitor_test.go +237 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "reflect"
5+ "slices"
56 "testing"
67 )
78
@@ -417,3 +418,239 @@ func TestSetNamedTransitions(t *testing.T) {
417418 })
418419 }
419420 }
421+
422+// Under fixed overflow, fullscreen moves the window to its own workspace
423+// below; workspace up/down still work and leaving brings it back in place.
424+func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
425+ m := monitor()
426+ m.SetOverflow(OverflowFixed)
427+ for id := WindowID(1); id <= 3; id++ {
428+ m.AddWindow(id)
429+ }
430+ m.Current().FocusID(2)
431+ m.ToggleFullscreen()
432+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2}, {}}) || m.Active != 1 {
433+ t.Fatal(got, m.Active)
434+ }
435+ if p := m.Layout(); !slices.ContainsFunc(p, func(p Placement) bool { return p.ID == 2 && p.Fullscreen && !p.Hidden }) {
436+ t.Fatal(p)
437+ }
438+ m.Apply(ActionFocusWorkspaceUp)
439+ if m.Active != 0 {
440+ t.Fatal("stuck on the fullscreen workspace")
441+ }
442+ m.Apply(ActionFocusWorkspaceDown)
443+ m.ToggleFullscreen()
444+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 {
445+ t.Fatal(got, m.Active)
446+ }
447+ if id, _ := m.Focused(); id != 2 {
448+ t.Fatal("focus", id)
449+ }
450+
451+ // A client request moves it too, without taking the user along
452+ // unless the window is the focused one on screen.
453+ m.Current().FocusID(1)
454+ m.SetFullscreen(3, true)
455+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) || m.Active != 0 {
456+ t.Fatal(got, m.Active)
457+ }
458+ m.SetFullscreen(3, false)
459+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
460+ t.Fatal(got)
461+ }
462+
463+ // Closing the fullscreen window drops its workspace and returns.
464+ m.Current().FocusID(3)
465+ m.ToggleFullscreen()
466+ m.RemoveWindow(3)
467+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 {
468+ t.Fatal(got, m.Active)
469+ }
470+
471+ // Scroll overflow keeps the niri behaviour: same workspace.
472+ m.SetOverflow(OverflowScroll)
473+ m.ToggleFullscreen()
474+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) {
475+ t.Fatal(got)
476+ }
477+}
478+
479+// The fullscreen workspace link survives only while it holds just its
480+// window; exits restore the exact place and never move the user's focus.
481+func TestMonitorFixedFullscreenEdges(t *testing.T) {
482+ fixed := func() *Monitor {
483+ m := monitor()
484+ m.SetOverflow(OverflowFixed)
485+ for id := WindowID(1); id <= 3; id++ {
486+ m.AddWindow(id)
487+ }
488+ return m
489+ }
490+ t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) {
491+ m := fixed()
492+ m.Current().FocusID(2)
493+ m.ToggleFullscreen()
494+ m.AddWindow(4)
495+ if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 {
496+ t.Fatal(fs.Floats, fs.fullscreen)
497+ }
498+ m.SetFullscreen(2, false)
499+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 {
500+ t.Fatal(got, m.Active)
501+ }
502+ })
503+ t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) {
504+ m := fixed()
505+ w := m.Current()
506+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}}
507+ w.FocusID(3)
508+ m.ToggleFullscreen()
509+ m.AddWindow(7)
510+ m.RemoveWindow(1)
511+ m.ToggleFullscreen()
512+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) {
513+ t.Fatal(got)
514+ }
515+ })
516+ t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
517+ m := fixed()
518+ m.Current().FocusID(2)
519+ m.ToggleFullscreen()
520+ m.AddFloating(4, 10, 10) // focused dialog
521+ m.ToggleFullscreen()
522+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
523+ t.Fatal(got, m.Current().Floats)
524+ }
525+ })
526+ t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) {
527+ m := fixed()
528+ m.Current().FocusID(2)
529+ m.ToggleFullscreen()
530+ m.AddFloating(4, 10, 10)
531+ m.SetFullscreen(4, true)
532+ m.SetFullscreen(4, false)
533+ m.ToggleFullscreen()
534+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
535+ t.Fatal(got)
536+ }
537+ })
538+ t.Run("moved in by hand it floats", func(t *testing.T) {
539+ m := fixed()
540+ m.Current().FocusID(2)
541+ m.ToggleFullscreen()
542+ m.Apply(ActionFocusWorkspaceUp)
543+ m.Current().FocusID(1)
544+ m.Apply(ActionMoveToWorkspaceDown)
545+ if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
546+ t.Fatal(fs.Floats)
547+ }
548+ })
549+ t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
550+ m := fixed()
551+ m.Current().FocusID(2)
552+ m.ToggleFullscreen()
553+ m.AddFloating(4, 10, 10)
554+ m.Apply(ActionFocusWorkspaceUp)
555+ m.AddFloating(9, 10, 10)
556+ m.RemoveWindow(2)
557+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 {
558+ t.Fatal(got, m.Current().Floats)
559+ }
560+ if id, _ := m.Focused(); id != 9 {
561+ t.Fatal("focus", id)
562+ }
563+ })
564+ t.Run("stack of three keeps its column", func(t *testing.T) {
565+ m := fixed()
566+ w := m.Current()
567+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}}
568+ w.FocusID(3)
569+ m.ToggleFullscreen()
570+ m.RemoveWindow(2)
571+ m.ToggleFullscreen()
572+ if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) {
573+ t.Fatal(w.Columns)
574+ }
575+ })
576+ t.Run("client exit keeps the user's focus", func(t *testing.T) {
577+ m := fixed()
578+ m.Current().FocusID(1)
579+ m.SetFullscreen(3, true)
580+ m.SetFullscreen(3, false)
581+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
582+ t.Fatal("focus moved to", id, m.Active)
583+ }
584+ m.Current().FocusID(3)
585+ m.SetFullscreen(1, true)
586+ m.SetFullscreen(1, false)
587+ if id, _ := m.Focused(); id != 3 {
588+ t.Fatal("focus moved to", id)
589+ }
590+ })
591+ t.Run("stacked slot window returns to its row", func(t *testing.T) {
592+ m := fixed()
593+ w := m.Current()
594+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}}
595+ w.FocusID(3)
596+ m.ToggleFullscreen()
597+ m.ToggleFullscreen()
598+ if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 {
599+ t.Fatal(w.Columns, w.Focus)
600+ }
601+ })
602+ t.Run("moved out by hand unlinks it", func(t *testing.T) {
603+ m := fixed()
604+ m.Current().FocusID(2)
605+ m.ToggleFullscreen()
606+ m.Apply(ActionMoveToWorkspaceUp)
607+ m.AddWindow(4)
608+ m.RemoveWindow(4)
609+ if m.Active != 1 {
610+ t.Fatal("view jumped to", m.Active)
611+ }
612+ })
613+ t.Run("client exit on screen keeps the window focused", func(t *testing.T) {
614+ m := fixed()
615+ m.Current().FocusID(1)
616+ m.ToggleFullscreen()
617+ m.SetFullscreen(1, false)
618+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
619+ t.Fatal("focus", id, m.Active)
620+ }
621+ })
622+ t.Run("stack gone: back as a column", func(t *testing.T) {
623+ m := fixed()
624+ w := m.Current()
625+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}}
626+ w.FocusID(3)
627+ m.ToggleFullscreen()
628+ m.RemoveWindow(2)
629+ m.ToggleFullscreen()
630+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 {
631+ t.Fatal(got, m.Current().Columns)
632+ }
633+ })
634+ t.Run("floating window", func(t *testing.T) {
635+ m := fixed()
636+ m.AddFloating(4, 10, 10)
637+ m.ToggleFullscreen()
638+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 {
639+ t.Fatal(got, m.Active)
640+ }
641+ m.ToggleFullscreen()
642+ if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus {
643+ t.Fatal(m.Active, w.Floats)
644+ }
645+ })
646+ t.Run("origin gone unlinks it", func(t *testing.T) {
647+ m := fixed()
648+ m.Current().FocusID(2)
649+ m.ToggleFullscreen()
650+ m.take(m.Workspaces[0])
651+ m.ToggleFullscreen()
652+ if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 {
653+ t.Fatal(id)
654+ }
655+ })
656+}
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "reflect"4 "reflect"
5+ "slices"
5 "testing"6 "testing"
6 )7 )
7 8
@@ -417,3 +418,239 @@ func TestSetNamedTransitions(t *testing.T) {
417 })418 })
418 }419 }
419 }420 }
421+
422+// Under fixed overflow, fullscreen moves the window to its own workspace
423+// below; workspace up/down still work and leaving brings it back in place.
424+func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
425+ m := monitor()
426+ m.SetOverflow(OverflowFixed)
427+ for id := WindowID(1); id <= 3; id++ {
428+ m.AddWindow(id)
429+ }
430+ m.Current().FocusID(2)
431+ m.ToggleFullscreen()
432+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2}, {}}) || m.Active != 1 {
433+ t.Fatal(got, m.Active)
434+ }
435+ if p := m.Layout(); !slices.ContainsFunc(p, func(p Placement) bool { return p.ID == 2 && p.Fullscreen && !p.Hidden }) {
436+ t.Fatal(p)
437+ }
438+ m.Apply(ActionFocusWorkspaceUp)
439+ if m.Active != 0 {
440+ t.Fatal("stuck on the fullscreen workspace")
441+ }
442+ m.Apply(ActionFocusWorkspaceDown)
443+ m.ToggleFullscreen()
444+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 {
445+ t.Fatal(got, m.Active)
446+ }
447+ if id, _ := m.Focused(); id != 2 {
448+ t.Fatal("focus", id)
449+ }
450+
451+ // A client request moves it too, without taking the user along
452+ // unless the window is the focused one on screen.
453+ m.Current().FocusID(1)
454+ m.SetFullscreen(3, true)
455+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) || m.Active != 0 {
456+ t.Fatal(got, m.Active)
457+ }
458+ m.SetFullscreen(3, false)
459+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
460+ t.Fatal(got)
461+ }
462+
463+ // Closing the fullscreen window drops its workspace and returns.
464+ m.Current().FocusID(3)
465+ m.ToggleFullscreen()
466+ m.RemoveWindow(3)
467+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 {
468+ t.Fatal(got, m.Active)
469+ }
470+
471+ // Scroll overflow keeps the niri behaviour: same workspace.
472+ m.SetOverflow(OverflowScroll)
473+ m.ToggleFullscreen()
474+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) {
475+ t.Fatal(got)
476+ }
477+}
478+
479+// The fullscreen workspace link survives only while it holds just its
480+// window; exits restore the exact place and never move the user's focus.
481+func TestMonitorFixedFullscreenEdges(t *testing.T) {
482+ fixed := func() *Monitor {
483+ m := monitor()
484+ m.SetOverflow(OverflowFixed)
485+ for id := WindowID(1); id <= 3; id++ {
486+ m.AddWindow(id)
487+ }
488+ return m
489+ }
490+ t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) {
491+ m := fixed()
492+ m.Current().FocusID(2)
493+ m.ToggleFullscreen()
494+ m.AddWindow(4)
495+ if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 {
496+ t.Fatal(fs.Floats, fs.fullscreen)
497+ }
498+ m.SetFullscreen(2, false)
499+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 {
500+ t.Fatal(got, m.Active)
501+ }
502+ })
503+ t.Run("a joined window lands after its stack when columns shifted", func(t *testing.T) {
504+ m := fixed()
505+ w := m.Current()
506+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{5}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{6}}}
507+ w.FocusID(3)
508+ m.ToggleFullscreen()
509+ m.AddWindow(7)
510+ m.RemoveWindow(1)
511+ m.ToggleFullscreen()
512+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{5, 2, 3, 7, 6}, {}}) {
513+ t.Fatal(got)
514+ }
515+ })
516+ t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
517+ m := fixed()
518+ m.Current().FocusID(2)
519+ m.ToggleFullscreen()
520+ m.AddFloating(4, 10, 10) // focused dialog
521+ m.ToggleFullscreen()
522+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
523+ t.Fatal(got, m.Current().Floats)
524+ }
525+ })
526+ t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) {
527+ m := fixed()
528+ m.Current().FocusID(2)
529+ m.ToggleFullscreen()
530+ m.AddFloating(4, 10, 10)
531+ m.SetFullscreen(4, true)
532+ m.SetFullscreen(4, false)
533+ m.ToggleFullscreen()
534+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
535+ t.Fatal(got)
536+ }
537+ })
538+ t.Run("moved in by hand it floats", func(t *testing.T) {
539+ m := fixed()
540+ m.Current().FocusID(2)
541+ m.ToggleFullscreen()
542+ m.Apply(ActionFocusWorkspaceUp)
543+ m.Current().FocusID(1)
544+ m.Apply(ActionMoveToWorkspaceDown)
545+ if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
546+ t.Fatal(fs.Floats)
547+ }
548+ })
549+ t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
550+ m := fixed()
551+ m.Current().FocusID(2)
552+ m.ToggleFullscreen()
553+ m.AddFloating(4, 10, 10)
554+ m.Apply(ActionFocusWorkspaceUp)
555+ m.AddFloating(9, 10, 10)
556+ m.RemoveWindow(2)
557+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 {
558+ t.Fatal(got, m.Current().Floats)
559+ }
560+ if id, _ := m.Focused(); id != 9 {
561+ t.Fatal("focus", id)
562+ }
563+ })
564+ t.Run("stack of three keeps its column", func(t *testing.T) {
565+ m := fixed()
566+ w := m.Current()
567+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3, 5}}}
568+ w.FocusID(3)
569+ m.ToggleFullscreen()
570+ m.RemoveWindow(2)
571+ m.ToggleFullscreen()
572+ if len(w.Columns) != 2 || !slices.Equal(w.Columns[1].Windows, []WindowID{3, 5}) {
573+ t.Fatal(w.Columns)
574+ }
575+ })
576+ t.Run("client exit keeps the user's focus", func(t *testing.T) {
577+ m := fixed()
578+ m.Current().FocusID(1)
579+ m.SetFullscreen(3, true)
580+ m.SetFullscreen(3, false)
581+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
582+ t.Fatal("focus moved to", id, m.Active)
583+ }
584+ m.Current().FocusID(3)
585+ m.SetFullscreen(1, true)
586+ m.SetFullscreen(1, false)
587+ if id, _ := m.Focused(); id != 3 {
588+ t.Fatal("focus moved to", id)
589+ }
590+ })
591+ t.Run("stacked slot window returns to its row", func(t *testing.T) {
592+ m := fixed()
593+ w := m.Current()
594+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}}
595+ w.FocusID(3)
596+ m.ToggleFullscreen()
597+ m.ToggleFullscreen()
598+ if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 {
599+ t.Fatal(w.Columns, w.Focus)
600+ }
601+ })
602+ t.Run("moved out by hand unlinks it", func(t *testing.T) {
603+ m := fixed()
604+ m.Current().FocusID(2)
605+ m.ToggleFullscreen()
606+ m.Apply(ActionMoveToWorkspaceUp)
607+ m.AddWindow(4)
608+ m.RemoveWindow(4)
609+ if m.Active != 1 {
610+ t.Fatal("view jumped to", m.Active)
611+ }
612+ })
613+ t.Run("client exit on screen keeps the window focused", func(t *testing.T) {
614+ m := fixed()
615+ m.Current().FocusID(1)
616+ m.ToggleFullscreen()
617+ m.SetFullscreen(1, false)
618+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
619+ t.Fatal("focus", id, m.Active)
620+ }
621+ })
622+ t.Run("stack gone: back as a column", func(t *testing.T) {
623+ m := fixed()
624+ w := m.Current()
625+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}}, {Windows: []WindowID{4}}}
626+ w.FocusID(3)
627+ m.ToggleFullscreen()
628+ m.RemoveWindow(2)
629+ m.ToggleFullscreen()
630+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3, 4}, {}}) || len(m.Current().Columns) != 3 {
631+ t.Fatal(got, m.Current().Columns)
632+ }
633+ })
634+ t.Run("floating window", func(t *testing.T) {
635+ m := fixed()
636+ m.AddFloating(4, 10, 10)
637+ m.ToggleFullscreen()
638+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 {
639+ t.Fatal(got, m.Active)
640+ }
641+ m.ToggleFullscreen()
642+ if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus {
643+ t.Fatal(m.Active, w.Floats)
644+ }
645+ })
646+ t.Run("origin gone unlinks it", func(t *testing.T) {
647+ m := fixed()
648+ m.Current().FocusID(2)
649+ m.ToggleFullscreen()
650+ m.take(m.Workspaces[0])
651+ m.ToggleFullscreen()
652+ if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 {
653+ t.Fatal(id)
654+ }
655+ })
656+}
M internal/core/slots.go +3 -2
@@ -141,8 +141,9 @@ func (c *Core) releaseSlots() {
141141 if st.window == 0 {
142142 continue
143143 }
144- _, w := c.byName(key.workspace)
145- if w == nil || !w.inSlot(st.window, key.index) {
144+ sc, w := c.byName(key.workspace)
145+ // A slot window away in its own fullscreen workspace keeps its slot.
146+ if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) {
146147 st.window = 0
147148 }
148149 }
@@ -141,8 +141,9 @@ func (c *Core) releaseSlots() {
141 if st.window == 0 {141 if st.window == 0 {
142 continue142 continue
143 }143 }
144- _, w := c.byName(key.workspace)144+ sc, w := c.byName(key.workspace)
145- if w == nil || !w.inSlot(st.window, key.index) {145+ // A slot window away in its own fullscreen workspace keeps its slot.
146+ if w == nil || !w.inSlot(st.window, key.index) && !sc.mon.awayInSlot(w, st.window, key.index) {
146 st.window = 0147 st.window = 0
147 }148 }
148 }149 }
M internal/core/slots_test.go +25 -1
@@ -23,7 +23,8 @@ type slotRig struct {
2323 }
2424
2525 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
26-// by Alt+d, on a 100x80 output.
26+// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27+// fixed overflow.
2728 func startSlots(t *testing.T, numbered ...bool) *slotRig {
2829 t.Helper()
2930 cfg := config.Defaults()
@@ -34,6 +35,9 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
3435 {Index: 2, Width: "30%", Argv: []string{"foot"}},
3536 }}}
3637 cfg.Binds["Alt+d"] = "workspace dev"
38+ if len(numbered) > 1 {
39+ cfg.Layout.Overflow = "fixed"
40+ }
3741 r := &slotRig{
3842 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
3943 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
@@ -367,3 +371,23 @@ func TestStartupCommands(t *testing.T) {
367371 }
368372 }
369373 }
374+
375+// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376+// slot: coming back to dev spawns no duplicate.
377+func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378+ r := startSlots(t, true, true)
379+ r.fill(t)
380+ r.press(t, "d") // on dev, focus on code (slot 1)
381+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383+ scene(t, r.scenes)
384+ r.press(t, "d") // leave and show dev while code is away
385+ r.press(t, "d")
386+ noSpawn(t, r.spawn)
387+ // code opens a second window while fullscreen: the slot stays.
388+ r.client <- ports.WindowMapped{ID: 9}
389+ scene(t, r.scenes)
390+ r.press(t, "d")
391+ r.press(t, "d")
392+ noSpawn(t, r.spawn)
393+}
@@ -23,7 +23,8 @@ type slotRig struct {
23 }23 }
24 24
25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled25 // startSlots runs core with a hidden "dev" workspace of two slots, toggled
26-// by Alt+d, on a 100x80 output.26+// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27+// fixed overflow.
27 func startSlots(t *testing.T, numbered ...bool) *slotRig {28 func startSlots(t *testing.T, numbered ...bool) *slotRig {
28 t.Helper()29 t.Helper()
29 cfg := config.Defaults()30 cfg := config.Defaults()
@@ -34,6 +35,9 @@ func startSlots(t *testing.T, numbered ...bool) *slotRig {
34 {Index: 2, Width: "30%", Argv: []string{"foot"}},35 {Index: 2, Width: "30%", Argv: []string{"foot"}},
35 }}}36 }}}
36 cfg.Binds["Alt+d"] = "workspace dev"37 cfg.Binds["Alt+d"] = "workspace dev"
38+ if len(numbered) > 1 {
39+ cfg.Layout.Overflow = "fixed"
40+ }
37 r := &slotRig{41 r := &slotRig{
38 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),42 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
39 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),43 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
@@ -367,3 +371,23 @@ func TestStartupCommands(t *testing.T) {
367 }371 }
368 }372 }
369 }373 }
374+
375+// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376+// slot: coming back to dev spawns no duplicate.
377+func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378+ r := startSlots(t, true, true)
379+ r.fill(t)
380+ r.press(t, "d") // on dev, focus on code (slot 1)
381+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382+ r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383+ scene(t, r.scenes)
384+ r.press(t, "d") // leave and show dev while code is away
385+ r.press(t, "d")
386+ noSpawn(t, r.spawn)
387+ // code opens a second window while fullscreen: the slot stays.
388+ r.client <- ports.WindowMapped{ID: 9}
389+ scene(t, r.scenes)
390+ r.press(t, "d")
391+ r.press(t, "d")
392+ noSpawn(t, r.spawn)
393+}

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close