detached 3fea6c2

fix(layout): keep the fullscreen workspace link only while it holds its window alone

commit
3fea6c2217b99201f0953262b0ab0c7a3f020e49
author
brice · · 2026-09-25 20:10 UTC
parent
4b313c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +190 -41
M internal/core/action.go +2 -0
@@ -222,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect {
222222 case ActionCycleColumnWidth:
223223 w.CycleWidth()
224224 case ActionToggleFullscreen:
225+ // In place only: binds go through Monitor.Apply, which gives a
226+ // fixed-overflow fullscreen its own workspace.
225227 w.ToggleFullscreen()
226228 case ActionCloseWindow:
227229 id, _ := w.Focused()
@@ -222,6 +222,8 @@ func (w *Workspace) Apply(a Action) Effect {
222 case ActionCycleColumnWidth:222 case ActionCycleColumnWidth:
223 w.CycleWidth()223 w.CycleWidth()
224 case ActionToggleFullscreen:224 case ActionToggleFullscreen:
225+ // In place only: binds go through Monitor.Apply, which gives a
226+ // fixed-overflow fullscreen its own workspace.
225 w.ToggleFullscreen()227 w.ToggleFullscreen()
226 case ActionCloseWindow:228 case ActionCloseWindow:
227 id, _ := w.Focused()229 id, _ := w.Focused()
M internal/core/layout.go +12 -3
@@ -137,9 +137,18 @@ type Workspace struct {
137137 // termAt is when core last spawned a terminal for this workspace.
138138 termAt time.Time
139139 // 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
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+ col, row, slot int
149+ stacked bool
150+ width Width
151+ float *Float
143152 }
144153
145154 // Float is a floating window and its client size, logical.
@@ -137,9 +137,18 @@ type Workspace struct {
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 window139 // origin is set on the workspace a fixed-overflow fullscreen window
140- // moved to; the window returns to column originCol there.140+ // moved to; back is where it returns there (Monitor.fullscreenHome).
141- origin *Workspace141+ origin *Workspace
142- originCol int142+ 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+ col, row, slot int
149+ stacked bool
150+ width Width
151+ float *Float
143 }152 }
144 153
145 // Float is a floating window and its client size, logical.154 // Float is a floating window and its client size, logical.
M internal/core/monitor.go +94 -38
@@ -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, w.origin = nil, nil, false, "", nil
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 {
@@ -245,26 +246,31 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
245246 func (m *Monitor) RemoveWindow(id WindowID) {
246247 if w, _ := m.find(id); w != nil {
247248 w.RemoveWindow(id)
248- if w.origin != nil && w.empty() {
249- m.leaveFullscreen(w)
249+ if w.origin != nil && w.empty() && m.has(w.origin) {
250+ shown := m.Current() == w
251+ origin := w.origin
252+ m.take(w)
253+ if shown {
254+ m.show(origin)
255+ }
250256 }
251257 m.normalize()
252258 }
253259 }
254260
255261 // 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.
262+// gets its own workspace, as with the bind. A client request never moves
263+// the user (ADR 011): the view follows only a window that is focused and
264+// on screen, and a window coming back does not take the focus.
259265 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
260266 w, _ := m.find(id)
261267 switch {
262268 case w == nil:
263- case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id):
269+ case on && m.ownWorkspace(w, id):
264270 focused, _ := w.Focused()
265271 m.enterFullscreen(w, id, w == m.Current() && focused == id)
266272 case !on && w.origin != nil && w.fullscreen == id:
267- m.leaveFullscreen(w)
273+ m.leaveFullscreen(w, false)
268274 default:
269275 w.SetFullscreen(id, on)
270276 }
@@ -280,8 +286,8 @@ func (m *Monitor) ToggleFullscreen() {
280286 switch {
281287 case !ok:
282288 case w.origin != nil && w.fullscreen == id:
283- m.leaveFullscreen(w)
284- case w.fullscreen != id && m.ownWorkspace(w, id):
289+ m.leaveFullscreen(w, true)
290+ case m.ownWorkspace(w, id):
285291 m.enterFullscreen(w, id, true)
286292 default:
287293 w.ToggleFullscreen()
@@ -290,52 +296,86 @@ func (m *Monitor) ToggleFullscreen() {
290296 }
291297
292298 // ownWorkspace reports whether going fullscreen moves the window to its own
293-// workspace: fixed overflow, a tiled window, and others to leave behind.
299+// workspace: fixed overflow hides every other window behind a fullscreen
300+// one and never scrolls, so it would leave the user stuck. Not needed when
301+// the window is alone, or already has its own workspace.
294302 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
303+ return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w)
296304 }
297305
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.
306+// enterFullscreen moves window id from w to a new workspace just below the
307+// numbered one on screen, fullscreen, and shows it when show is set. It
308+// remembers the window's place in w to return there.
300309 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)
304310 fs := m.newWorkspace()
305311 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
312+ fs.origin = w
313+ if f := w.floatIndex(id); f >= 0 {
314+ fl := w.Floats[f]
315+ fs.back.float = &fl
316+ w.RemoveWindow(id)
317+ fs.AddFloating(id, fl.W, fl.H)
318+ } else {
319+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
320+ c := w.Columns[col]
321+ fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width}
322+ w.RemoveWindow(id)
323+ fs.AddWindow(id)
324+ fs.Columns[0].Width = c.Width
325+ }
309326 fs.SetFullscreen(id, true)
310- at := indexOf(m.Workspaces, w) + 1
327+ at := m.Active + 1
328+ if i := indexOf(m.Workspaces, w); i >= 0 {
329+ at = i + 1
330+ }
311331 m.Workspaces = slices.Insert(m.Workspaces, at, fs)
312332 if at <= m.Active {
313333 m.Active++
314334 }
315335 if show {
316- m.Focus(at)
336+ m.show(fs)
317337 }
318338 }
319339
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) {
340+// leaveFullscreen sends the window of fs back to its place in the origin
341+// and drops fs; the view goes along when fs was on screen. focus is set
342+// for the user's bind: the window gets the focus back there.
343+func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
344+ if m.fullscreenHome(); fs.origin == nil {
345+ fs.SetFullscreen(fs.fullscreen, false)
328346 return
329347 }
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
348+ origin, back, id := fs.origin, fs.back, fs.fullscreen
349+ fs.origin, fs.back = nil, origPlace{}
350+ fs.RemoveWindow(id)
351+ switch prev := origin.Focus; {
352+ case back.float != nil && focus:
353+ origin.AddFloating(id, back.float.W, back.float.H)
354+ case back.float != nil:
355+ // At the bottom: the top float, maybe focused, stays on top.
356+ origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
357+ case back.stacked && back.col < len(origin.Columns):
358+ c := &origin.Columns[back.col]
359+ row := min(back.row, len(c.Windows))
360+ c.Windows = slices.Insert(c.Windows, row, id)
361+ if focus {
362+ origin.Focus, c.Focus, origin.floatFocus = back.col, row, false
363+ } else if row <= c.Focus && len(c.Windows) > 1 {
364+ c.Focus++
365+ }
366+ default:
367+ at := min(back.col, len(origin.Columns))
368+ origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot})
369+ if focus {
370+ origin.floatFocus = false
371+ } else if len(origin.Columns) > 1 {
372+ origin.Focus = prev
373+ if at <= prev {
374+ origin.Focus++
375+ }
376+ }
338377 }
378+ origin.scroll()
339379 shown := m.Current() == fs
340380 m.take(fs)
341381 if shown {
@@ -343,6 +383,22 @@ func (m *Monitor) leaveFullscreen(fs *Workspace) {
343383 }
344384 }
345385
386+// fullscreenHome keeps each fullscreen workspace linked to its origin only
387+// while it holds just its fullscreen window and the origin is still here.
388+// Anything else (another window opened or moved in, the window activated
389+// away from fullscreen, the origin gone to another monitor) makes it a
390+// normal workspace, so a later exit never moves the wrong window.
391+func (m *Monitor) fullscreenHome() {
392+ for _, w := range m.all() {
393+ if w.origin == nil {
394+ continue
395+ }
396+ if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen {
397+ w.origin, w.back = nil, origPlace{}
398+ }
399+ }
400+}
401+
346402 // MoveToWorkspace moves the focused window to numbered workspace index i;
347403 // focus stays here.
348404 func (m *Monitor) MoveToWorkspace(i int) {
@@ -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, w.origin = nil, nil, false, "", nil120+ 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 {
@@ -245,26 +246,31 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
245 func (m *Monitor) RemoveWindow(id WindowID) {246 func (m *Monitor) RemoveWindow(id WindowID) {
246 if w, _ := m.find(id); w != nil {247 if w, _ := m.find(id); w != nil {
247 w.RemoveWindow(id)248 w.RemoveWindow(id)
248- if w.origin != nil && w.empty() {249+ if w.origin != nil && w.empty() && m.has(w.origin) {
249- m.leaveFullscreen(w)250+ shown := m.Current() == w
251+ origin := w.origin
252+ m.take(w)
253+ if shown {
254+ m.show(origin)
255+ }
250 }256 }
251 m.normalize()257 m.normalize()
252 }258 }
253 }259 }
254 260
255 // SetFullscreen applies a client request. Under fixed overflow the window261 // SetFullscreen applies a client request. Under fixed overflow the window
256-// gets its own workspace, as with the bind; the view follows only when the262+// gets its own workspace, as with the bind. A client request never moves
257-// window is the focused one on screen, so a request never takes the user263+// the user (ADR 011): the view follows only a window that is focused and
258-// away from what they look at.264+// on screen, and a window coming back does not take the focus.
259 func (m *Monitor) SetFullscreen(id WindowID, on bool) {265 func (m *Monitor) SetFullscreen(id WindowID, on bool) {
260 w, _ := m.find(id)266 w, _ := m.find(id)
261 switch {267 switch {
262 case w == nil:268 case w == nil:
263- case on && w.origin == nil && w.fullscreen != id && m.ownWorkspace(w, id):269+ case on && m.ownWorkspace(w, id):
264 focused, _ := w.Focused()270 focused, _ := w.Focused()
265 m.enterFullscreen(w, id, w == m.Current() && focused == id)271 m.enterFullscreen(w, id, w == m.Current() && focused == id)
266 case !on && w.origin != nil && w.fullscreen == id:272 case !on && w.origin != nil && w.fullscreen == id:
267- m.leaveFullscreen(w)273+ m.leaveFullscreen(w, false)
268 default:274 default:
269 w.SetFullscreen(id, on)275 w.SetFullscreen(id, on)
270 }276 }
@@ -280,8 +286,8 @@ func (m *Monitor) ToggleFullscreen() {
280 switch {286 switch {
281 case !ok:287 case !ok:
282 case w.origin != nil && w.fullscreen == id:288 case w.origin != nil && w.fullscreen == id:
283- m.leaveFullscreen(w)289+ m.leaveFullscreen(w, true)
284- case w.fullscreen != id && m.ownWorkspace(w, id):290+ case m.ownWorkspace(w, id):
285 m.enterFullscreen(w, id, true)291 m.enterFullscreen(w, id, true)
286 default:292 default:
287 w.ToggleFullscreen()293 w.ToggleFullscreen()
@@ -290,52 +296,86 @@ func (m *Monitor) ToggleFullscreen() {
290 }296 }
291 297
292 // ownWorkspace reports whether going fullscreen moves the window to its own298 // ownWorkspace reports whether going fullscreen moves the window to its own
293-// workspace: fixed overflow, a tiled window, and others to leave behind.299+// workspace: fixed overflow hides every other window behind a fullscreen
300+// one and never scrolls, so it would leave the user stuck. Not needed when
301+// the window is alone, or already has its own workspace.
294 func (m *Monitor) ownWorkspace(w *Workspace, id WindowID) bool {302 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) >= 0303+ return w.Overflow == OverflowFixed && w.origin == nil && w.fullscreen != id && len(w.windows()) > 1 && m.has(w)
296 }304 }
297 305
298-// enterFullscreen moves the tiled window id from w to a new workspace just306+// enterFullscreen moves window id from w to a new workspace just below the
299-// below it, fullscreen, and shows it when show is set.307+// numbered one on screen, fullscreen, and shows it when show is set. It
308+// remembers the window's place in w to return there.
300 func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {309 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()310 fs := m.newWorkspace()
305 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns311 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
306- fs.origin, fs.originCol = w, col312+ fs.origin = w
307- fs.AddWindow(id)313+ if f := w.floatIndex(id); f >= 0 {
308- fs.Columns[0].Width = width314+ fl := w.Floats[f]
315+ fs.back.float = &fl
316+ w.RemoveWindow(id)
317+ fs.AddFloating(id, fl.W, fl.H)
318+ } else {
319+ col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
320+ c := w.Columns[col]
321+ fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, stacked: len(c.Windows) > 1, width: c.Width}
322+ w.RemoveWindow(id)
323+ fs.AddWindow(id)
324+ fs.Columns[0].Width = c.Width
325+ }
309 fs.SetFullscreen(id, true)326 fs.SetFullscreen(id, true)
310- at := indexOf(m.Workspaces, w) + 1327+ at := m.Active + 1
328+ if i := indexOf(m.Workspaces, w); i >= 0 {
329+ at = i + 1
330+ }
311 m.Workspaces = slices.Insert(m.Workspaces, at, fs)331 m.Workspaces = slices.Insert(m.Workspaces, at, fs)
312 if at <= m.Active {332 if at <= m.Active {
313 m.Active++333 m.Active++
314 }334 }
315 if show {335 if show {
316- m.Focus(at)336+ m.show(fs)
317 }337 }
318 }338 }
319 339
320-// leaveFullscreen sends the window of the fullscreen workspace fs back to340+// leaveFullscreen sends the window of fs back to its place in the origin
321-// its origin column and drops fs. The view goes along when fs was on341+// and drops fs; the view goes along when fs was on screen. focus is set
322-// screen. Without the origin, fs becomes a normal workspace.342+// for the user's bind: the window gets the focus back there.
323-func (m *Monitor) leaveFullscreen(fs *Workspace) {343+func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
324- origin := fs.origin344+ if m.fullscreenHome(); fs.origin == nil {
325- fs.origin = nil345+ fs.SetFullscreen(fs.fullscreen, false)
326- fs.fullscreen = 0
327- if !m.has(origin) {
328 return346 return
329 }347 }
330- if len(fs.Columns) > 0 {348+ origin, back, id := fs.origin, fs.back, fs.fullscreen
331- col := fs.Columns[0]349+ fs.origin, fs.back = nil, origPlace{}
332- col.Focus = 0350+ fs.RemoveWindow(id)
333- fs.Columns = fs.Columns[1:]351+ switch prev := origin.Focus; {
334- origin.insertColumn(fs.originCol, col)352+ case back.float != nil && focus:
335- }353+ origin.AddFloating(id, back.float.W, back.float.H)
336- if !fs.empty() {354+ case back.float != nil:
337- return355+ // At the bottom: the top float, maybe focused, stays on top.
356+ origin.Floats = slices.Insert(origin.Floats, 0, Float{ID: id, W: back.float.W, H: back.float.H})
357+ case back.stacked && back.col < len(origin.Columns):
358+ c := &origin.Columns[back.col]
359+ row := min(back.row, len(c.Windows))
360+ c.Windows = slices.Insert(c.Windows, row, id)
361+ if focus {
362+ origin.Focus, c.Focus, origin.floatFocus = back.col, row, false
363+ } else if row <= c.Focus && len(c.Windows) > 1 {
364+ c.Focus++
365+ }
366+ default:
367+ at := min(back.col, len(origin.Columns))
368+ origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot})
369+ if focus {
370+ origin.floatFocus = false
371+ } else if len(origin.Columns) > 1 {
372+ origin.Focus = prev
373+ if at <= prev {
374+ origin.Focus++
375+ }
376+ }
338 }377 }
378+ origin.scroll()
339 shown := m.Current() == fs379 shown := m.Current() == fs
340 m.take(fs)380 m.take(fs)
341 if shown {381 if shown {
@@ -343,6 +383,22 @@ func (m *Monitor) leaveFullscreen(fs *Workspace) {
343 }383 }
344 }384 }
345 385
386+// fullscreenHome keeps each fullscreen workspace linked to its origin only
387+// while it holds just its fullscreen window and the origin is still here.
388+// Anything else (another window opened or moved in, the window activated
389+// away from fullscreen, the origin gone to another monitor) makes it a
390+// normal workspace, so a later exit never moves the wrong window.
391+func (m *Monitor) fullscreenHome() {
392+ for _, w := range m.all() {
393+ if w.origin == nil {
394+ continue
395+ }
396+ if !m.has(w.origin) || w.fullscreen == 0 || len(w.windows()) != 1 || w.windows()[0] != w.fullscreen {
397+ w.origin, w.back = nil, origPlace{}
398+ }
399+ }
400+}
401+
346 // MoveToWorkspace moves the focused window to numbered workspace index i;402 // MoveToWorkspace moves the focused window to numbered workspace index i;
347 // focus stays here.403 // focus stays here.
348 func (m *Monitor) MoveToWorkspace(i int) {404 func (m *Monitor) MoveToWorkspace(i int) {
M internal/core/monitor_test.go +82 -0
@@ -475,3 +475,85 @@ func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
475475 t.Fatal(got)
476476 }
477477 }
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("another window opened there unlinks it", func(t *testing.T) {
491+ m := fixed()
492+ m.Current().FocusID(2)
493+ m.ToggleFullscreen()
494+ m.AddWindow(4)
495+ m.SetFullscreen(2, false)
496+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) {
497+ t.Fatal(got)
498+ }
499+ })
500+ t.Run("client exit keeps the user's focus", func(t *testing.T) {
501+ m := fixed()
502+ m.Current().FocusID(1)
503+ m.SetFullscreen(3, true)
504+ m.SetFullscreen(3, false)
505+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
506+ t.Fatal("focus moved to", id, m.Active)
507+ }
508+ m.Current().FocusID(3)
509+ m.SetFullscreen(1, true)
510+ m.SetFullscreen(1, false)
511+ if id, _ := m.Focused(); id != 3 {
512+ t.Fatal("focus moved to", id)
513+ }
514+ })
515+ t.Run("stacked slot window returns to its row", func(t *testing.T) {
516+ m := fixed()
517+ w := m.Current()
518+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}}
519+ w.FocusID(3)
520+ m.ToggleFullscreen()
521+ m.ToggleFullscreen()
522+ if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 {
523+ t.Fatal(w.Columns, w.Focus)
524+ }
525+ })
526+ t.Run("moved out by hand unlinks it", func(t *testing.T) {
527+ m := fixed()
528+ m.Current().FocusID(2)
529+ m.ToggleFullscreen()
530+ m.Apply(ActionMoveToWorkspaceUp)
531+ m.AddWindow(4)
532+ m.RemoveWindow(4)
533+ if m.Active != 1 {
534+ t.Fatal("view jumped to", m.Active)
535+ }
536+ })
537+ t.Run("floating window", func(t *testing.T) {
538+ m := fixed()
539+ m.AddFloating(4, 10, 10)
540+ m.ToggleFullscreen()
541+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 {
542+ t.Fatal(got, m.Active)
543+ }
544+ m.ToggleFullscreen()
545+ if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus {
546+ t.Fatal(m.Active, w.Floats)
547+ }
548+ })
549+ t.Run("origin gone unlinks it", func(t *testing.T) {
550+ m := fixed()
551+ m.Current().FocusID(2)
552+ m.ToggleFullscreen()
553+ m.take(m.Workspaces[0])
554+ m.ToggleFullscreen()
555+ if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 {
556+ t.Fatal(id)
557+ }
558+ })
559+}
@@ -475,3 +475,85 @@ func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
475 t.Fatal(got)475 t.Fatal(got)
476 }476 }
477 }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("another window opened there unlinks it", func(t *testing.T) {
491+ m := fixed()
492+ m.Current().FocusID(2)
493+ m.ToggleFullscreen()
494+ m.AddWindow(4)
495+ m.SetFullscreen(2, false)
496+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {2, 4}, {}}) {
497+ t.Fatal(got)
498+ }
499+ })
500+ t.Run("client exit keeps the user's focus", func(t *testing.T) {
501+ m := fixed()
502+ m.Current().FocusID(1)
503+ m.SetFullscreen(3, true)
504+ m.SetFullscreen(3, false)
505+ if id, _ := m.Focused(); id != 1 || m.Active != 0 {
506+ t.Fatal("focus moved to", id, m.Active)
507+ }
508+ m.Current().FocusID(3)
509+ m.SetFullscreen(1, true)
510+ m.SetFullscreen(1, false)
511+ if id, _ := m.Focused(); id != 3 {
512+ t.Fatal("focus moved to", id)
513+ }
514+ })
515+ t.Run("stacked slot window returns to its row", func(t *testing.T) {
516+ m := fixed()
517+ w := m.Current()
518+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Slot: 2}}
519+ w.FocusID(3)
520+ m.ToggleFullscreen()
521+ m.ToggleFullscreen()
522+ if !reflect.DeepEqual(w.Columns[1], Column{Windows: []WindowID{2, 3}, Focus: 1, Slot: 2}) || w.Focus != 1 {
523+ t.Fatal(w.Columns, w.Focus)
524+ }
525+ })
526+ t.Run("moved out by hand unlinks it", func(t *testing.T) {
527+ m := fixed()
528+ m.Current().FocusID(2)
529+ m.ToggleFullscreen()
530+ m.Apply(ActionMoveToWorkspaceUp)
531+ m.AddWindow(4)
532+ m.RemoveWindow(4)
533+ if m.Active != 1 {
534+ t.Fatal("view jumped to", m.Active)
535+ }
536+ })
537+ t.Run("floating window", func(t *testing.T) {
538+ m := fixed()
539+ m.AddFloating(4, 10, 10)
540+ m.ToggleFullscreen()
541+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}, {}}) || m.Active != 1 || m.Workspaces[1].floatIndex(4) != 0 {
542+ t.Fatal(got, m.Active)
543+ }
544+ m.ToggleFullscreen()
545+ if w := m.Current(); m.Active != 0 || w.floatIndex(4) != 0 || !w.floatFocus {
546+ t.Fatal(m.Active, w.Floats)
547+ }
548+ })
549+ t.Run("origin gone unlinks it", func(t *testing.T) {
550+ m := fixed()
551+ m.Current().FocusID(2)
552+ m.ToggleFullscreen()
553+ m.take(m.Workspaces[0])
554+ m.ToggleFullscreen()
555+ if id, _ := m.Focused(); id != 2 || m.Current().fullscreen != 0 {
556+ t.Fatal(id)
557+ }
558+ })
559+}

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