detached 3bc98da

fix(layout): windows joining a fullscreen workspace float then tile home, dialogs never steal focus or the link

commit
3bc98dac890c459d79179210631900c162c42607
author
brice · · 2026-09-25 20:22 UTC
parent
12f359f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +78 -17
M internal/core/layout.go +3 -1
@@ -145,8 +145,10 @@ type Workspace struct {
145145 // origPlace remembers where a window was: its column, its row in a stacked
146146 // column, the column's width and slot, or its floating size.
147147 type origPlace struct {
148- // id is the window that went fullscreen.
148+ // id is the window that went fullscreen; tiled lists the windows that
149+ // joined its workspace tiled, floating there until they go home.
149150 id WindowID
151+ tiled []WindowID
150152 col, row, slot int
151153 // stacked holds the windows left in the column: the window returns to
152154 // its row in the column that still holds one of them.
@@ -145,8 +145,10 @@ type Workspace struct {
145 // origPlace remembers where a window was: its column, its row in a stacked145 // 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.146 // column, the column's width and slot, or its floating size.
147 type origPlace struct {147 type origPlace struct {
148- // id is the window that went fullscreen.148+ // id is the window that went fullscreen; tiled lists the windows that
149+ // joined its workspace tiled, floating there until they go home.
149 id WindowID150 id WindowID
151+ tiled []WindowID
150 col, row, slot int152 col, row, slot int
151 // stacked holds the windows left in the column: the window returns to153 // stacked holds the windows left in the column: the window returns to
152 // its row in the column that still holds one of them.154 // its row in the column that still holds one of them.
M internal/core/monitor.go +39 -8
@@ -231,7 +231,7 @@ func (m *Monitor) AddWindow(id WindowID) {
231231 return
232232 }
233233 if cur := m.Current(); cur.origin != nil {
234- cur.AddFloating(id, 0, 0)
234+ cur.joinFullscreen(id)
235235 m.normalize()
236236 return
237237 }
@@ -254,10 +254,10 @@ func (m *Monitor) RemoveWindow(id WindowID) {
254254 if w, _ := m.find(id); w != nil {
255255 if w.origin != nil && id == w.back.id && m.has(w.origin) {
256256 // Its dialogs go home with the view.
257- origin, at := w.origin, w.back.col
257+ origin, back := w.origin, w.back
258258 w.RemoveWindow(id)
259259 w.origin, w.back = nil, origPlace{}
260- m.foldInto(w, origin, at, false)
260+ m.foldInto(w, origin, back.col, false, back.tiled)
261261 } else {
262262 w.RemoveWindow(id)
263263 }
@@ -273,6 +273,9 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
273273 w, _ := m.find(id)
274274 switch {
275275 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.
276279 case on && m.ownWorkspace(w, id):
277280 focused, _ := w.Focused()
278281 m.enterFullscreen(w, id, w == m.Current() && focused == id)
@@ -364,7 +367,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
364367 origin, back, id := fs.origin, fs.back, fs.back.id
365368 fs.origin, fs.back = nil, origPlace{}
366369 fs.RemoveWindow(id)
367- defer m.foldInto(fs, origin, back.col+1, focus)
370+ defer m.foldInto(fs, origin, back.col+1, focus, back.tiled)
368371 switch prev := origin.Focus; {
369372 case back.float != nil && focus:
370373 origin.AddFloating(id, back.float.W, back.float.H)
@@ -405,7 +408,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
405408 // foldInto moves what is left in fs (windows opened while fullscreen: its
406409 // dialogs) to origin, tiled ones from column at, floating ones on top, and
407410 // drops fs. The view goes along when fs was on screen.
408-func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {
411+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) {
409412 shown := m.Current() == fs
410413 at = min(max(at, 0), len(origin.Columns))
411414 for _, c := range slices.Backward(fs.Columns) {
@@ -414,8 +417,25 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {
414417 origin.Focus++
415418 }
416419 }
417- origin.Floats = append(origin.Floats, fs.Floats...)
418- origin.floatFocus = origin.floatFocus || len(fs.Floats) > 0 && (focus || shown) && fs.floatFocus
420+ var floats []Float
421+ for _, f := range fs.Floats {
422+ if !slices.Contains(tiled, f.ID) {
423+ floats = append(floats, f)
424+ continue
425+ }
426+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}})
427+ if at <= origin.Focus && len(origin.Columns) > 1 {
428+ origin.Focus++
429+ }
430+ at++
431+ }
432+ if focus || shown {
433+ origin.Floats = append(origin.Floats, floats...)
434+ origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus
435+ } else {
436+ // The user is elsewhere: below, the focused float keeps the focus.
437+ origin.Floats = append(floats, origin.Floats...)
438+ }
419439 fs.Columns, fs.Floats = nil, nil
420440 origin.scroll()
421441 m.take(fs)
@@ -469,13 +489,24 @@ func (m *Monitor) MoveToWorkspace(i int) {
469489 fl := cur.Floats[f]
470490 cur.RemoveWindow(id)
471491 m.Workspaces[i].AddFloating(id, fl.W, fl.H)
492+ } else if to := m.Workspaces[i]; to.origin != nil {
493+ cur.RemoveWindow(id)
494+ to.joinFullscreen(id)
472495 } else {
473496 cur.RemoveWindow(id)
474- m.Workspaces[i].AddWindow(id)
497+ to.AddWindow(id)
475498 }
476499 m.normalize()
477500 }
478501
502+// joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
503+// above the fullscreen window, which would hide it (fixed overflow), and
504+// tiles again once home.
505+func (fs *Workspace) joinFullscreen(id WindowID) {
506+ fs.AddFloating(id, 0, 0)
507+ fs.back.tiled = append(fs.back.tiled, id)
508+}
509+
479510 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
480511
481512 // Layout places every window: the workspace on screen laid out, others hidden.
@@ -231,7 +231,7 @@ func (m *Monitor) AddWindow(id WindowID) {
231 return231 return
232 }232 }
233 if cur := m.Current(); cur.origin != nil {233 if cur := m.Current(); cur.origin != nil {
234- cur.AddFloating(id, 0, 0)234+ cur.joinFullscreen(id)
235 m.normalize()235 m.normalize()
236 return236 return
237 }237 }
@@ -254,10 +254,10 @@ func (m *Monitor) RemoveWindow(id WindowID) {
254 if w, _ := m.find(id); w != nil {254 if w, _ := m.find(id); w != nil {
255 if w.origin != nil && id == w.back.id && m.has(w.origin) {255 if w.origin != nil && id == w.back.id && m.has(w.origin) {
256 // Its dialogs go home with the view.256 // Its dialogs go home with the view.
257- origin, at := w.origin, w.back.col257+ origin, back := w.origin, w.back
258 w.RemoveWindow(id)258 w.RemoveWindow(id)
259 w.origin, w.back = nil, origPlace{}259 w.origin, w.back = nil, origPlace{}
260- m.foldInto(w, origin, at, false)260+ m.foldInto(w, origin, back.col, false, back.tiled)
261 } else {261 } else {
262 w.RemoveWindow(id)262 w.RemoveWindow(id)
263 }263 }
@@ -273,6 +273,9 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) {
273 w, _ := m.find(id)273 w, _ := m.find(id)
274 switch {274 switch {
275 case w == nil: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.
276 case on && m.ownWorkspace(w, id):279 case on && m.ownWorkspace(w, id):
277 focused, _ := w.Focused()280 focused, _ := w.Focused()
278 m.enterFullscreen(w, id, w == m.Current() && focused == id)281 m.enterFullscreen(w, id, w == m.Current() && focused == id)
@@ -364,7 +367,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
364 origin, back, id := fs.origin, fs.back, fs.back.id367 origin, back, id := fs.origin, fs.back, fs.back.id
365 fs.origin, fs.back = nil, origPlace{}368 fs.origin, fs.back = nil, origPlace{}
366 fs.RemoveWindow(id)369 fs.RemoveWindow(id)
367- defer m.foldInto(fs, origin, back.col+1, focus)370+ defer m.foldInto(fs, origin, back.col+1, focus, back.tiled)
368 switch prev := origin.Focus; {371 switch prev := origin.Focus; {
369 case back.float != nil && focus:372 case back.float != nil && focus:
370 origin.AddFloating(id, back.float.W, back.float.H)373 origin.AddFloating(id, back.float.W, back.float.H)
@@ -405,7 +408,7 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
405 // foldInto moves what is left in fs (windows opened while fullscreen: its408 // foldInto moves what is left in fs (windows opened while fullscreen: its
406 // dialogs) to origin, tiled ones from column at, floating ones on top, and409 // dialogs) to origin, tiled ones from column at, floating ones on top, and
407 // drops fs. The view goes along when fs was on screen.410 // drops fs. The view goes along when fs was on screen.
408-func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {411+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool, tiled []WindowID) {
409 shown := m.Current() == fs412 shown := m.Current() == fs
410 at = min(max(at, 0), len(origin.Columns))413 at = min(max(at, 0), len(origin.Columns))
411 for _, c := range slices.Backward(fs.Columns) {414 for _, c := range slices.Backward(fs.Columns) {
@@ -414,8 +417,25 @@ func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {
414 origin.Focus++417 origin.Focus++
415 }418 }
416 }419 }
417- origin.Floats = append(origin.Floats, fs.Floats...)420+ var floats []Float
418- origin.floatFocus = origin.floatFocus || len(fs.Floats) > 0 && (focus || shown) && fs.floatFocus421+ for _, f := range fs.Floats {
422+ if !slices.Contains(tiled, f.ID) {
423+ floats = append(floats, f)
424+ continue
425+ }
426+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: []WindowID{f.ID}})
427+ if at <= origin.Focus && len(origin.Columns) > 1 {
428+ origin.Focus++
429+ }
430+ at++
431+ }
432+ if focus || shown {
433+ origin.Floats = append(origin.Floats, floats...)
434+ origin.floatFocus = origin.floatFocus || len(floats) > 0 && fs.floatFocus
435+ } else {
436+ // The user is elsewhere: below, the focused float keeps the focus.
437+ origin.Floats = append(floats, origin.Floats...)
438+ }
419 fs.Columns, fs.Floats = nil, nil439 fs.Columns, fs.Floats = nil, nil
420 origin.scroll()440 origin.scroll()
421 m.take(fs)441 m.take(fs)
@@ -469,13 +489,24 @@ func (m *Monitor) MoveToWorkspace(i int) {
469 fl := cur.Floats[f]489 fl := cur.Floats[f]
470 cur.RemoveWindow(id)490 cur.RemoveWindow(id)
471 m.Workspaces[i].AddFloating(id, fl.W, fl.H)491 m.Workspaces[i].AddFloating(id, fl.W, fl.H)
492+ } else if to := m.Workspaces[i]; to.origin != nil {
493+ cur.RemoveWindow(id)
494+ to.joinFullscreen(id)
472 } else {495 } else {
473 cur.RemoveWindow(id)496 cur.RemoveWindow(id)
474- m.Workspaces[i].AddWindow(id)497+ to.AddWindow(id)
475 }498 }
476 m.normalize()499 m.normalize()
477 }500 }
478 501
502+// joinFullscreen adds a tiled window to a fullscreen workspace fs: it floats
503+// above the fullscreen window, which would hide it (fixed overflow), and
504+// tiles again once home.
505+func (fs *Workspace) joinFullscreen(id WindowID) {
506+ fs.AddFloating(id, 0, 0)
507+ fs.back.tiled = append(fs.back.tiled, id)
508+}
509+
479 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }510 func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() }
480 511
481 // Layout places every window: the workspace on screen laid out, others hidden.512 // Layout places every window: the workspace on screen laid out, others hidden.
M internal/core/monitor_test.go +36 -8
@@ -487,7 +487,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
487487 }
488488 return m
489489 }
490- t.Run("a dialog opened there floats and follows it home", func(t *testing.T) {
490+ t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) {
491491 m := fixed()
492492 m.Current().FocusID(2)
493493 m.ToggleFullscreen()
@@ -496,28 +496,56 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
496496 t.Fatal(fs.Floats, fs.fullscreen)
497497 }
498498 m.SetFullscreen(2, false)
499- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {
500- t.Fatal(got, m.Active, m.Current().Floats)
499+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 {
500+ t.Fatal(got, m.Active)
501501 }
502502 })
503503 t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
504504 m := fixed()
505505 m.Current().FocusID(2)
506506 m.ToggleFullscreen()
507- m.AddWindow(4) // focused dialog
507+ m.AddFloating(4, 10, 10) // focused dialog
508508 m.ToggleFullscreen()
509509 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
510510 t.Fatal(got, m.Current().Floats)
511511 }
512512 })
513- t.Run("closing it brings its dialogs home", func(t *testing.T) {
513+ t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) {
514514 m := fixed()
515515 m.Current().FocusID(2)
516516 m.ToggleFullscreen()
517- m.AddWindow(4)
517+ m.AddFloating(4, 10, 10)
518+ m.SetFullscreen(4, true)
519+ m.SetFullscreen(4, false)
520+ m.ToggleFullscreen()
521+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
522+ t.Fatal(got)
523+ }
524+ })
525+ t.Run("moved in by hand it floats", func(t *testing.T) {
526+ m := fixed()
527+ m.Current().FocusID(2)
528+ m.ToggleFullscreen()
529+ m.Apply(ActionFocusWorkspaceUp)
530+ m.Current().FocusID(1)
531+ m.Apply(ActionMoveToWorkspaceDown)
532+ if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
533+ t.Fatal(fs.Floats)
534+ }
535+ })
536+ t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
537+ m := fixed()
538+ m.Current().FocusID(2)
539+ m.ToggleFullscreen()
540+ m.AddFloating(4, 10, 10)
541+ m.Apply(ActionFocusWorkspaceUp)
542+ m.AddFloating(9, 10, 10)
518543 m.RemoveWindow(2)
519- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {
520- t.Fatal(got, m.Active)
544+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 {
545+ t.Fatal(got, m.Current().Floats)
546+ }
547+ if id, _ := m.Focused(); id != 9 {
548+ t.Fatal("focus", id)
521549 }
522550 })
523551 t.Run("stack of three keeps its column", func(t *testing.T) {
@@ -487,7 +487,7 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
487 }487 }
488 return m488 return m
489 }489 }
490- t.Run("a dialog opened there floats and follows it home", func(t *testing.T) {490+ t.Run("a window opened there floats, then tiles after it at home", func(t *testing.T) {
491 m := fixed()491 m := fixed()
492 m.Current().FocusID(2)492 m.Current().FocusID(2)
493 m.ToggleFullscreen()493 m.ToggleFullscreen()
@@ -496,28 +496,56 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
496 t.Fatal(fs.Floats, fs.fullscreen)496 t.Fatal(fs.Floats, fs.fullscreen)
497 }497 }
498 m.SetFullscreen(2, false)498 m.SetFullscreen(2, false)
499- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {499+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 4, 3}, {}}) || m.Active != 0 {
500- t.Fatal(got, m.Active, m.Current().Floats)500+ t.Fatal(got, m.Active)
501 }501 }
502 })502 })
503 t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {503 t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
504 m := fixed()504 m := fixed()
505 m.Current().FocusID(2)505 m.Current().FocusID(2)
506 m.ToggleFullscreen()506 m.ToggleFullscreen()
507- m.AddWindow(4) // focused dialog507+ m.AddFloating(4, 10, 10) // focused dialog
508 m.ToggleFullscreen()508 m.ToggleFullscreen()
509 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {509 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
510 t.Fatal(got, m.Current().Floats)510 t.Fatal(got, m.Current().Floats)
511 }511 }
512 })512 })
513- t.Run("closing it brings its dialogs home", func(t *testing.T) {513+ t.Run("a dialog asking fullscreen keeps the link", func(t *testing.T) {
514 m := fixed()514 m := fixed()
515 m.Current().FocusID(2)515 m.Current().FocusID(2)
516 m.ToggleFullscreen()516 m.ToggleFullscreen()
517- m.AddWindow(4)517+ m.AddFloating(4, 10, 10)
518+ m.SetFullscreen(4, true)
519+ m.SetFullscreen(4, false)
520+ m.ToggleFullscreen()
521+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) {
522+ t.Fatal(got)
523+ }
524+ })
525+ t.Run("moved in by hand it floats", func(t *testing.T) {
526+ m := fixed()
527+ m.Current().FocusID(2)
528+ m.ToggleFullscreen()
529+ m.Apply(ActionFocusWorkspaceUp)
530+ m.Current().FocusID(1)
531+ m.Apply(ActionMoveToWorkspaceDown)
532+ if fs := m.Workspaces[1]; fs.floatIndex(1) != 0 || fs.origin == nil {
533+ t.Fatal(fs.Floats)
534+ }
535+ })
536+ t.Run("closing it brings its dialogs home, below a focused float", func(t *testing.T) {
537+ m := fixed()
538+ m.Current().FocusID(2)
539+ m.ToggleFullscreen()
540+ m.AddFloating(4, 10, 10)
541+ m.Apply(ActionFocusWorkspaceUp)
542+ m.AddFloating(9, 10, 10)
518 m.RemoveWindow(2)543 m.RemoveWindow(2)
519- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {544+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Current().floatIndex(4) != 0 {
520- t.Fatal(got, m.Active)545+ t.Fatal(got, m.Current().Floats)
546+ }
547+ if id, _ := m.Focused(); id != 9 {
548+ t.Fatal("focus", id)
521 }549 }
522 })550 })
523 t.Run("stack of three keeps its column", func(t *testing.T) {551 t.Run("stack of three keeps its column", func(t *testing.T) {

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