detached 12f359f

fix(layout): track the fullscreen window by id, dialogs float and follow it home

commit
12f359fcd1cb5492751a2ff95aae8ad9cbef29e4
author
brice · · 2026-09-25 20:20 UTC
parent
461a201
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +74 -24
M internal/core/layout.go +2 -0
@@ -145,6 +145,8 @@ 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.
149+ id WindowID
148150 col, row, slot int
149151 // stacked holds the windows left in the column: the window returns to
150152 // its row in the column that still holds one of them.
@@ -145,6 +145,8 @@ 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.
149+ id WindowID
148 col, row, slot int150 col, row, slot int
149 // stacked holds the windows left in the column: the window returns to151 // stacked holds the windows left in the column: the window returns to
150 // its row in the column that still holds one of them.152 // its row in the column that still holds one of them.
M internal/core/monitor.go +47 -21
@@ -223,11 +223,18 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
223223 return nil, -1
224224 }
225225
226-// 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.
227229 func (m *Monitor) AddWindow(id WindowID) {
228230 if w, _ := m.find(id); w != nil {
229231 return
230232 }
233+ if cur := m.Current(); cur.origin != nil {
234+ cur.AddFloating(id, 0, 0)
235+ m.normalize()
236+ return
237+ }
231238 m.Current().AddWindow(id)
232239 m.normalize()
233240 }
@@ -245,14 +252,14 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
245252 // workspace, except that closing a fullscreen workspace goes back to its origin.
246253 func (m *Monitor) RemoveWindow(id WindowID) {
247254 if w, _ := m.find(id); w != nil {
248- w.RemoveWindow(id)
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- }
255+ if w.origin != nil && id == w.back.id && m.has(w.origin) {
256+ // Its dialogs go home with the view.
257+ origin, at := w.origin, w.back.col
258+ w.RemoveWindow(id)
259+ w.origin, w.back = nil, origPlace{}
260+ m.foldInto(w, origin, at, false)
261+ } else {
262+ w.RemoveWindow(id)
256263 }
257264 m.normalize()
258265 }
@@ -286,7 +293,8 @@ func (m *Monitor) ToggleFullscreen() {
286293 id, ok := w.Focused()
287294 switch {
288295 case !ok:
289- case w.origin != nil && w.fullscreen == id:
296+ case w.origin != nil && w.fullscreen == w.back.id:
297+ // From its window or one of its dialogs.
290298 m.leaveFullscreen(w, true)
291299 case m.ownWorkspace(w, id):
292300 m.enterFullscreen(w, id, true)
@@ -311,6 +319,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
311319 fs := m.newWorkspace()
312320 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
313321 fs.origin = w
322+ fs.back.id = id
314323 if f := w.floatIndex(id); f >= 0 {
315324 fl := w.Floats[f]
316325 fs.back.float = &fl
@@ -319,7 +328,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
319328 } else {
320329 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
321330 c := w.Columns[col]
322- fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
331+ fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
323332 for _, v := range c.Windows {
324333 if v != id {
325334 fs.back.stacked = append(fs.back.stacked, v)
@@ -343,17 +352,19 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
343352 }
344353 }
345354
346-// leaveFullscreen sends the window of fs back to its place in the origin
347-// and drops fs; the view goes along when fs was on screen. focus is set
348-// for the user's bind: the window gets the focus back there.
355+// leaveFullscreen sends the window of fs back to its place in the origin,
356+// with the windows opened there meanwhile (its dialogs) just after it, and
357+// drops fs; the view goes along when fs was on screen. focus is set when
358+// the user was on the window: it gets the focus back there.
349359 func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
350360 if m.fullscreenHome(); fs.origin == nil {
351361 fs.SetFullscreen(fs.fullscreen, false)
352362 return
353363 }
354- origin, back, id := fs.origin, fs.back, fs.fullscreen
364+ origin, back, id := fs.origin, fs.back, fs.back.id
355365 fs.origin, fs.back = nil, origPlace{}
356366 fs.RemoveWindow(id)
367+ defer m.foldInto(fs, origin, back.col+1, focus)
357368 switch prev := origin.Focus; {
358369 case back.float != nil && focus:
359370 origin.AddFloating(id, back.float.W, back.float.H)
@@ -389,20 +400,35 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
389400 }
390401 }
391402 }
403+}
404+
405+// 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, and
407+// drops fs. The view goes along when fs was on screen.
408+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {
409+ shown := m.Current() == fs
410+ at = min(max(at, 0), len(origin.Columns))
411+ for _, c := range slices.Backward(fs.Columns) {
412+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus})
413+ if at <= origin.Focus && len(origin.Columns) > 1 {
414+ origin.Focus++
415+ }
416+ }
417+ origin.Floats = append(origin.Floats, fs.Floats...)
418+ origin.floatFocus = origin.floatFocus || len(fs.Floats) > 0 && (focus || shown) && fs.floatFocus
419+ fs.Columns, fs.Floats = nil, nil
392420 origin.scroll()
393- if m.Current() == fs && (focus || fs.empty()) {
421+ m.take(fs)
422+ if shown {
394423 m.show(origin)
395424 }
396- if fs.empty() {
397- m.take(fs)
398- }
399425 }
400426
401427 // awayInSlot reports whether id is away from slot n of w, fullscreen in a
402428 // workspace that returns it there.
403429 func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
404430 for _, fs := range m.all() {
405- if fs.origin == w && fs.fullscreen == id && fs.back.slot == n {
431+ if fs.origin == w && fs.back.id == id && fs.back.slot == n {
406432 return true
407433 }
408434 }
@@ -424,7 +450,7 @@ func (m *Monitor) fullscreenHome() {
424450 if w.origin == nil {
425451 continue
426452 }
427- if !m.has(w.origin) || w.fullscreen == 0 || !w.has(w.fullscreen) {
453+ if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) {
428454 w.origin, w.back = nil, origPlace{}
429455 }
430456 }
@@ -223,11 +223,18 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
223 return nil, -1223 return nil, -1
224 }224 }
225 225
226-// 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.
227 func (m *Monitor) AddWindow(id WindowID) {229 func (m *Monitor) AddWindow(id WindowID) {
228 if w, _ := m.find(id); w != nil {230 if w, _ := m.find(id); w != nil {
229 return231 return
230 }232 }
233+ if cur := m.Current(); cur.origin != nil {
234+ cur.AddFloating(id, 0, 0)
235+ m.normalize()
236+ return
237+ }
231 m.Current().AddWindow(id)238 m.Current().AddWindow(id)
232 m.normalize()239 m.normalize()
233 }240 }
@@ -245,14 +252,14 @@ func (m *Monitor) AddFloating(id WindowID, width, height int) {
245 // workspace, except that closing a fullscreen workspace goes back to its origin.252 // workspace, except that closing a fullscreen workspace goes back to its origin.
246 func (m *Monitor) RemoveWindow(id WindowID) {253 func (m *Monitor) RemoveWindow(id WindowID) {
247 if w, _ := m.find(id); w != nil {254 if w, _ := m.find(id); w != nil {
248- w.RemoveWindow(id)255+ if w.origin != nil && id == w.back.id && m.has(w.origin) {
249- if w.origin != nil && w.empty() && m.has(w.origin) {256+ // Its dialogs go home with the view.
250- shown := m.Current() == w257+ origin, at := w.origin, w.back.col
251- origin := w.origin258+ w.RemoveWindow(id)
252- m.take(w)259+ w.origin, w.back = nil, origPlace{}
253- if shown {260+ m.foldInto(w, origin, at, false)
254- m.show(origin)261+ } else {
255- }262+ w.RemoveWindow(id)
256 }263 }
257 m.normalize()264 m.normalize()
258 }265 }
@@ -286,7 +293,8 @@ func (m *Monitor) ToggleFullscreen() {
286 id, ok := w.Focused()293 id, ok := w.Focused()
287 switch {294 switch {
288 case !ok:295 case !ok:
289- case w.origin != nil && w.fullscreen == id:296+ case w.origin != nil && w.fullscreen == w.back.id:
297+ // From its window or one of its dialogs.
290 m.leaveFullscreen(w, true)298 m.leaveFullscreen(w, true)
291 case m.ownWorkspace(w, id):299 case m.ownWorkspace(w, id):
292 m.enterFullscreen(w, id, true)300 m.enterFullscreen(w, id, true)
@@ -311,6 +319,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
311 fs := m.newWorkspace()319 fs := m.newWorkspace()
312 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns320 fs.Overflow, fs.MaxColumns = w.Overflow, w.MaxColumns
313 fs.origin = w321 fs.origin = w
322+ fs.back.id = id
314 if f := w.floatIndex(id); f >= 0 {323 if f := w.floatIndex(id); f >= 0 {
315 fl := w.Floats[f]324 fl := w.Floats[f]
316 fs.back.float = &fl325 fs.back.float = &fl
@@ -319,7 +328,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
319 } else {328 } else {
320 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })329 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
321 c := w.Columns[col]330 c := w.Columns[col]
322- fs.back = origPlace{col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}331+ fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width}
323 for _, v := range c.Windows {332 for _, v := range c.Windows {
324 if v != id {333 if v != id {
325 fs.back.stacked = append(fs.back.stacked, v)334 fs.back.stacked = append(fs.back.stacked, v)
@@ -343,17 +352,19 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) {
343 }352 }
344 }353 }
345 354
346-// leaveFullscreen sends the window of fs back to its place in the origin355+// leaveFullscreen sends the window of fs back to its place in the origin,
347-// and drops fs; the view goes along when fs was on screen. focus is set356+// with the windows opened there meanwhile (its dialogs) just after it, and
348-// for the user's bind: the window gets the focus back there.357+// drops fs; the view goes along when fs was on screen. focus is set when
358+// the user was on the window: it gets the focus back there.
349 func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {359 func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
350 if m.fullscreenHome(); fs.origin == nil {360 if m.fullscreenHome(); fs.origin == nil {
351 fs.SetFullscreen(fs.fullscreen, false)361 fs.SetFullscreen(fs.fullscreen, false)
352 return362 return
353 }363 }
354- origin, back, id := fs.origin, fs.back, fs.fullscreen364+ origin, back, id := fs.origin, fs.back, fs.back.id
355 fs.origin, fs.back = nil, origPlace{}365 fs.origin, fs.back = nil, origPlace{}
356 fs.RemoveWindow(id)366 fs.RemoveWindow(id)
367+ defer m.foldInto(fs, origin, back.col+1, focus)
357 switch prev := origin.Focus; {368 switch prev := origin.Focus; {
358 case back.float != nil && focus:369 case back.float != nil && focus:
359 origin.AddFloating(id, back.float.W, back.float.H)370 origin.AddFloating(id, back.float.W, back.float.H)
@@ -389,20 +400,35 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
389 }400 }
390 }401 }
391 }402 }
403+}
404+
405+// 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, and
407+// drops fs. The view goes along when fs was on screen.
408+func (m *Monitor) foldInto(fs, origin *Workspace, at int, focus bool) {
409+ shown := m.Current() == fs
410+ at = min(max(at, 0), len(origin.Columns))
411+ for _, c := range slices.Backward(fs.Columns) {
412+ origin.Columns = slices.Insert(origin.Columns, at, Column{Windows: c.Windows, Width: c.Width, Focus: c.Focus})
413+ if at <= origin.Focus && len(origin.Columns) > 1 {
414+ origin.Focus++
415+ }
416+ }
417+ origin.Floats = append(origin.Floats, fs.Floats...)
418+ origin.floatFocus = origin.floatFocus || len(fs.Floats) > 0 && (focus || shown) && fs.floatFocus
419+ fs.Columns, fs.Floats = nil, nil
392 origin.scroll()420 origin.scroll()
393- if m.Current() == fs && (focus || fs.empty()) {421+ m.take(fs)
422+ if shown {
394 m.show(origin)423 m.show(origin)
395 }424 }
396- if fs.empty() {
397- m.take(fs)
398- }
399 }425 }
400 426
401 // awayInSlot reports whether id is away from slot n of w, fullscreen in a427 // awayInSlot reports whether id is away from slot n of w, fullscreen in a
402 // workspace that returns it there.428 // workspace that returns it there.
403 func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {429 func (m *Monitor) awayInSlot(w *Workspace, id WindowID, n int) bool {
404 for _, fs := range m.all() {430 for _, fs := range m.all() {
405- if fs.origin == w && fs.fullscreen == id && fs.back.slot == n {431+ if fs.origin == w && fs.back.id == id && fs.back.slot == n {
406 return true432 return true
407 }433 }
408 }434 }
@@ -424,7 +450,7 @@ func (m *Monitor) fullscreenHome() {
424 if w.origin == nil {450 if w.origin == nil {
425 continue451 continue
426 }452 }
427- if !m.has(w.origin) || w.fullscreen == 0 || !w.has(w.fullscreen) {453+ if !m.has(w.origin) || w.fullscreen != w.back.id || !w.has(w.back.id) {
428 w.origin, w.back = nil, origPlace{}454 w.origin, w.back = nil, origPlace{}
429 }455 }
430 }456 }
M internal/core/monitor_test.go +25 -3
@@ -487,14 +487,36 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
487487 }
488488 return m
489489 }
490- t.Run("a window opened there stays when it returns", func(t *testing.T) {
490+ t.Run("a dialog opened there floats and follows it home", func(t *testing.T) {
491491 m := fixed()
492492 m.Current().FocusID(2)
493493 m.ToggleFullscreen()
494494 m.AddWindow(4)
495- m.Current().MoveColumn(-1)
495+ if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 {
496+ t.Fatal(fs.Floats, fs.fullscreen)
497+ }
496498 m.SetFullscreen(2, false)
497- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {4}, {}}) || m.Active != 0 {
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)
501+ }
502+ })
503+ t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
504+ m := fixed()
505+ m.Current().FocusID(2)
506+ m.ToggleFullscreen()
507+ m.AddWindow(4) // focused dialog
508+ m.ToggleFullscreen()
509+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
510+ t.Fatal(got, m.Current().Floats)
511+ }
512+ })
513+ t.Run("closing it brings its dialogs home", func(t *testing.T) {
514+ m := fixed()
515+ m.Current().FocusID(2)
516+ m.ToggleFullscreen()
517+ m.AddWindow(4)
518+ m.RemoveWindow(2)
519+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {
498520 t.Fatal(got, m.Active)
499521 }
500522 })
@@ -487,14 +487,36 @@ func TestMonitorFixedFullscreenEdges(t *testing.T) {
487 }487 }
488 return m488 return m
489 }489 }
490- t.Run("a window opened there stays when it returns", func(t *testing.T) {490+ t.Run("a dialog opened there floats and follows it 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()
494 m.AddWindow(4)494 m.AddWindow(4)
495- m.Current().MoveColumn(-1)495+ if fs := m.Current(); fs.floatIndex(4) != 0 || fs.fullscreen != 2 {
496+ t.Fatal(fs.Floats, fs.fullscreen)
497+ }
496 m.SetFullscreen(2, false)498 m.SetFullscreen(2, false)
497- if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {4}, {}}) || m.Active != 0 {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)
501+ }
502+ })
503+ t.Run("the bind from a dialog returns the fullscreen window", func(t *testing.T) {
504+ m := fixed()
505+ m.Current().FocusID(2)
506+ m.ToggleFullscreen()
507+ m.AddWindow(4) // focused dialog
508+ m.ToggleFullscreen()
509+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3}, {}}) || m.Current().floatIndex(4) != 0 {
510+ t.Fatal(got, m.Current().Floats)
511+ }
512+ })
513+ t.Run("closing it brings its dialogs home", func(t *testing.T) {
514+ m := fixed()
515+ m.Current().FocusID(2)
516+ m.ToggleFullscreen()
517+ m.AddWindow(4)
518+ m.RemoveWindow(2)
519+ if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 3}, {}}) || m.Active != 0 || m.Current().floatIndex(4) != 0 {
498 t.Fatal(got, m.Active)520 t.Fatal(got, m.Active)
499 }521 }
500 })522 })

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close