detached 4b313c6

feat(layout): fixed-overflow fullscreen gets its own workspace below

commit
4b313c6cdd5c716b3f0206d9a0c4329d6f6b2986
author
brice · · 2026-09-25 19:59 UTC
parent
9bc7abf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +157 -5
M internal/core/action.go +3 -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)
@@ -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)
M internal/core/layout.go +4 -0
@@ -136,6 +136,10 @@ 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; the window returns to column originCol there.
141+ origin *Workspace
142+ originCol int
139143 }
140144
141145 // Float is a floating window and its client size, logical.
@@ -136,6 +136,10 @@ 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; the window returns to column originCol there.
141+ origin *Workspace
142+ originCol int
139 }143 }
140 144
141 // Float is a floating window and its client size, logical.145 // Float is a floating window and its client size, logical.
M internal/core/monitor.go +92 -5
@@ -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 = nil, nil, false, "", nil
121121 return &w
122122 }
123123
@@ -240,20 +240,107 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
240240 m.normalize()
241241 }
242242
243-// RemoveWindow drops the window wherever it is; focus stays on the active workspace.
243+// RemoveWindow drops the window wherever it is; focus stays on the active
244+// workspace, except that closing a fullscreen workspace goes back to its origin.
244245 func (m *Monitor) RemoveWindow(id WindowID) {
245246 if w, _ := m.find(id); w != nil {
246247 w.RemoveWindow(id)
248+ if w.origin != nil && w.empty() {
249+ m.leaveFullscreen(w)
250+ }
247251 m.normalize()
248252 }
249253 }
250254
251-// SetFullscreen applies a client request on the window's own workspace. It does
252-// not switch workspaces: client requests never move the user.
255+// SetFullscreen applies a client request. Under fixed overflow the window
256+// gets its own workspace, as with the bind; the view follows only when the
257+// window is the focused one on screen, so a request never takes the user
258+// away from what they look at.
253259 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
254- if w, _ := m.find(id); w != nil {
260+ w, _ := m.find(id)
261+ switch {
262+ case w == nil:
263+ case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id):
264+ focused, _ := w.Focused()
265+ m.enterFullscreen(w, id, w == m.Current() && focused == id)
266+ case !on && w.origin != nil && w.fullscreen == id:
267+ m.leaveFullscreen(w)
268+ default:
255269 w.SetFullscreen(id, on)
256270 }
271+ m.normalize()
272+}
273+
274+// ToggleFullscreen is the bind. Under fixed overflow the focused window
275+// moves to a new workspace below the current one, so workspace up/down
276+// still reach the others; toggling again sends it back where it was.
277+func (m *Monitor) ToggleFullscreen() {
278+ w := m.Current()
279+ id, ok := w.Focused()
280+ switch {
281+ case !ok:
282+ case w.origin != nil && w.fullscreen == id:
283+ m.leaveFullscreen(w)
284+ case w.fullscreen != id && m.ownWorkspace(w, id):
285+ m.enterFullscreen(w, id, true)
286+ default:
287+ w.ToggleFullscreen()
288+ }
289+ m.normalize()
290+}
291+
292+// ownWorkspace reports whether going fullscreen moves the window to its own
293+// workspace: fixed overflow, a tiled window, and others to leave behind.
294+func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
295+ return w.Overflow == OverflowFixed && w.floatIndex(id) < 0 && len(w.windows()) > 1 && indexOf(m.Workspaces, w) >= 0
296+}
297+
298+// enterFullscreen moves the tiled window id from w to a new workspace just
299+// below it, fullscreen, and shows it when show is set.
300+func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
301+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
302+ width := w.Columns[col].Width
303+ w.RemoveWindow(id)
304+ fs := m.newWorkspace()
305+ fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
306+ fs.origin, fs.originCol = w, col
307+ fs.AddWindow(id)
308+ fs.Columns[0].Width = width
309+ fs.SetFullscreen(id, true)
310+ at := indexOf(m.Workspaces, w) + 1
311+ m.Workspaces = slices.Insert(m.Workspaces, at, fs)
312+ if at <= m.Active {
313+ m.Active++
314+ }
315+ if show {
316+ m.Focus(at)
317+ }
318+}
319+
320+// leaveFullscreen sends the window of the fullscreen workspace fs back to
321+// its origin column and drops fs. The view goes along when fs was on
322+// screen. Without the origin, fs becomes a normal workspace.
323+func (m *Monitor) leaveFullscreen(fs *Workspace) {
324+ origin := fs.origin
325+ fs.origin = nil
326+ fs.fullscreen = 0
327+ if !m.has(origin) {
328+ return
329+ }
330+ if len(fs.Columns) > 0 {
331+ col := fs.Columns[0]
332+ col.Focus = 0
333+ fs.Columns = fs.Columns[1:]
334+ origin.insertColumn(fs.originCol, col)
335+ }
336+ if !fs.empty() {
337+ return
338+ }
339+ shown := m.Current() == fs
340+ m.take(fs)
341+ if shown {
342+ m.show(origin)
343+ }
257344 }
258345
259346 // MoveToWorkspace moves the focused window to numbered workspace index i;
@@ -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 = nil, nil, false, "", nil
121 return &w121 return &w
122 }122 }
123 123
@@ -240,20 +240,107 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
240 m.normalize()240 m.normalize()
241 }241 }
242 242
243-// RemoveWindow drops the window wherever it is; focus stays on the active workspace.243+// RemoveWindow drops the window wherever it is; focus stays on the active
244+// workspace, except that closing a fullscreen workspace goes back to its origin.
244 func (m *Monitor) RemoveWindow(id WindowID) {245 func (m *Monitor) RemoveWindow(id WindowID) {
245 if w, _ := m.find(id); w != nil {246 if w, _ := m.find(id); w != nil {
246 w.RemoveWindow(id)247 w.RemoveWindow(id)
248+ if w.origin != nil && w.empty() {
249+ m.leaveFullscreen(w)
250+ }
247 m.normalize()251 m.normalize()
248 }252 }
249 }253 }
250 254
251-// SetFullscreen applies a client request on the window's own workspace. It does255+// SetFullscreen applies a client request. Under fixed overflow the window
252-// not switch workspaces: client requests never move the user.256+// gets its own workspace, as with the bind; the view follows only when the
257+// window is the focused one on screen, so a request never takes the user
258+// away from what they look at.
253 func (m *Monitor) SetFullscreen(id WindowID, on bool) {259 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
254- if w, _ := m.find(id); w != nil {260+ w, _ := m.find(id)
261+ switch {
262+ case w == nil:
263+ case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id):
264+ focused, _ := w.Focused()
265+ m.enterFullscreen(w, id, w == m.Current() && focused == id)
266+ case !on && w.origin != nil && w.fullscreen == id:
267+ m.leaveFullscreen(w)
268+ default:
255 w.SetFullscreen(id, on)269 w.SetFullscreen(id, on)
256 }270 }
271+ m.normalize()
272+}
273+
274+// ToggleFullscreen is the bind. Under fixed overflow the focused window
275+// moves to a new workspace below the current one, so workspace up/down
276+// still reach the others; toggling again sends it back where it was.
277+func (m *Monitor) ToggleFullscreen() {
278+ w := m.Current()
279+ id, ok := w.Focused()
280+ switch {
281+ case !ok:
282+ case w.origin != nil && w.fullscreen == id:
283+ m.leaveFullscreen(w)
284+ case w.fullscreen != id && m.ownWorkspace(w, id):
285+ m.enterFullscreen(w, id, true)
286+ default:
287+ w.ToggleFullscreen()
288+ }
289+ m.normalize()
290+}
291+
292+// ownWorkspace reports whether going fullscreen moves the window to its own
293+// workspace: fixed overflow, a tiled window, and others to leave behind.
294+func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {
295+ return w.Overflow == OverflowFixed && w.floatIndex(id) < 0 && len(w.windows()) > 1 && indexOf(m.Workspaces, w) >= 0
296+}
297+
298+// enterFullscreen moves the tiled window id from w to a new workspace just
299+// below it, fullscreen, and shows it when show is set.
300+func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
301+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
302+ width := w.Columns[col].Width
303+ w.RemoveWindow(id)
304+ fs := m.newWorkspace()
305+ fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
306+ fs.origin, fs.originCol = w, col
307+ fs.AddWindow(id)
308+ fs.Columns[0].Width = width
309+ fs.SetFullscreen(id, true)
310+ at := indexOf(m.Workspaces, w) + 1
311+ m.Workspaces = slices.Insert(m.Workspaces, at, fs)
312+ if at <= m.Active {
313+ m.Active++
314+ }
315+ if show {
316+ m.Focus(at)
317+ }
318+}
319+
320+// leaveFullscreen sends the window of the fullscreen workspace fs back to
321+// its origin column and drops fs. The view goes along when fs was on
322+// screen. Without the origin, fs becomes a normal workspace.
323+func (m *Monitor) leaveFullscreen(fs *Workspace) {
324+ origin := fs.origin
325+ fs.origin = nil
326+ fs.fullscreen = 0
327+ if !m.has(origin) {
328+ return
329+ }
330+ if len(fs.Columns) > 0 {
331+ col := fs.Columns[0]
332+ col.Focus = 0
333+ fs.Columns = fs.Columns[1:]
334+ origin.insertColumn(fs.originCol, col)
335+ }
336+ if !fs.empty() {
337+ return
338+ }
339+ shown := m.Current() == fs
340+ m.take(fs)
341+ if shown {
342+ m.show(origin)
343+ }
257 }344 }
258 345
259 // MoveToWorkspace moves the focused window to numbered workspace index i;346 // MoveToWorkspace moves the focused window to numbered workspace index i;
M internal/core/monitor_test.go +58 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "reflect"
5+ "slices"
56 "testing"
67 )
78
@@ -417,3 +418,60 @@ 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+}
@@ -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,60 @@ 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+}

Keyboard shortcuts

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

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

Branches 26

Tags 10

↑↓ moveenter openesc close