detached 92f3c09

fix(core): show dialog chains over the cover

A dialog of a dialog (an overwrite confirm over a save dialog) was hidden under a fullscreen cover, or made it leave fullscreen when modal. overCover now follows the dialog parent chain.
commit
92f3c09d79b15b10d40ebdfb4f8174169ff834cc
author
brice · · 2026-10-13 04:16 UTC
parent
ee436b8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +77 -4
M internal/core/floating_test.go +56 -0
@@ -578,6 +578,62 @@ func TestActivateStashModalKeepsFullscreen(t *testing.T) {
578578 }
579579 }
580580
581+// A dialog of a dialog (an "Overwrite?" over a "Save as…") shows over
582+// the cover like its parent: modal chains of a stashed window or of the
583+// cover keep the game fullscreen, and a new one takes the focus.
584+func TestDialogChainOverCover(t *testing.T) {
585+ t.Run("stashed modal chain", func(t *testing.T) {
586+ w := workspace()
587+ w.AddWindow(14)
588+ w.ToggleWindowStash()
589+ w.ToggleStashVisible()
590+ w.AddDialog(9, 14, 20, 10)
591+ w.SetModal(9, true)
592+ w.AddDialog(8, 9, 10, 5)
593+ w.SetModal(8, true)
594+ w.AddFloating(72, 50, 40)
595+ w.SetFullscreen(72, true)
596+ w.Activate(14)
597+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
598+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
599+ }
600+ if p := placementOf(w.Layout(), 8); p.Hidden {
601+ t.Fatalf("confirm hidden: %+v", p)
602+ }
603+ })
604+ t.Run("stashed dialog maps a dialog", func(t *testing.T) {
605+ w := workspace()
606+ w.AddWindow(14)
607+ w.ToggleWindowStash()
608+ w.ToggleStashVisible()
609+ w.AddFloating(72, 50, 40)
610+ w.SetFullscreen(72, true)
611+ w.Activate(14)
612+ w.AddDialog(9, 14, 20, 10)
613+ w.AddDialog(8, 9, 10, 5)
614+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 {
615+ t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
616+ }
617+ if p := placementOf(w.Layout(), 8); p.Hidden {
618+ t.Fatalf("confirm hidden: %+v", p)
619+ }
620+ })
621+ t.Run("cover modal chain", func(t *testing.T) {
622+ w := workspace()
623+ w.AddFloating(72, 50, 40)
624+ w.AddDialog(9, 72, 20, 10)
625+ w.SetModal(9, true)
626+ w.AddDialog(8, 9, 10, 5)
627+ w.SetModal(8, true)
628+ w.SetFullscreen(72, true)
629+ w.AddWindow(1)
630+ w.Activate(72)
631+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
632+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
633+ }
634+ })
635+}
636+
581637 func placementOf(ps []Placement, id WindowID) Placement {
582638 if i := placementIndex(ps, id); i >= 0 {
583639 return ps[i]
@@ -578,6 +578,62 @@ func TestActivateStashModalKeepsFullscreen(t *testing.T) {
578 }578 }
579 }579 }
580 580
581+// A dialog of a dialog (an "Overwrite?" over a "Save as…") shows over
582+// the cover like its parent: modal chains of a stashed window or of the
583+// cover keep the game fullscreen, and a new one takes the focus.
584+func TestDialogChainOverCover(t *testing.T) {
585+ t.Run("stashed modal chain", func(t *testing.T) {
586+ w := workspace()
587+ w.AddWindow(14)
588+ w.ToggleWindowStash()
589+ w.ToggleStashVisible()
590+ w.AddDialog(9, 14, 20, 10)
591+ w.SetModal(9, true)
592+ w.AddDialog(8, 9, 10, 5)
593+ w.SetModal(8, true)
594+ w.AddFloating(72, 50, 40)
595+ w.SetFullscreen(72, true)
596+ w.Activate(14)
597+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
598+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
599+ }
600+ if p := placementOf(w.Layout(), 8); p.Hidden {
601+ t.Fatalf("confirm hidden: %+v", p)
602+ }
603+ })
604+ t.Run("stashed dialog maps a dialog", func(t *testing.T) {
605+ w := workspace()
606+ w.AddWindow(14)
607+ w.ToggleWindowStash()
608+ w.ToggleStashVisible()
609+ w.AddFloating(72, 50, 40)
610+ w.SetFullscreen(72, true)
611+ w.Activate(14)
612+ w.AddDialog(9, 14, 20, 10)
613+ w.AddDialog(8, 9, 10, 5)
614+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 {
615+ t.Fatalf("focused %d fullscreen %d", id, w.fullscreen)
616+ }
617+ if p := placementOf(w.Layout(), 8); p.Hidden {
618+ t.Fatalf("confirm hidden: %+v", p)
619+ }
620+ })
621+ t.Run("cover modal chain", func(t *testing.T) {
622+ w := workspace()
623+ w.AddFloating(72, 50, 40)
624+ w.AddDialog(9, 72, 20, 10)
625+ w.SetModal(9, true)
626+ w.AddDialog(8, 9, 10, 5)
627+ w.SetModal(8, true)
628+ w.SetFullscreen(72, true)
629+ w.AddWindow(1)
630+ w.Activate(72)
631+ if id, _ := w.Focused(); id != 8 || w.fullscreen != 72 || w.left != 0 {
632+ t.Fatalf("focused %d fullscreen %d left %d", id, w.fullscreen, w.left)
633+ }
634+ })
635+}
636+
581 func placementOf(ps []Placement, id WindowID) Placement {637 func placementOf(ps []Placement, id WindowID) Placement {
582 if i := placementIndex(ps, id); i >= 0 {638 if i := placementIndex(ps, id); i >= 0 {
583 return ps[i]639 return ps[i]
M internal/core/layout.go +21 -4
@@ -401,13 +401,29 @@ func (w *Workspace) focusModal(from WindowID) {
401401
402402 // overCover reports whether float f is drawn over the covering fullscreen
403403 // window: a dialog of the cover, or of a stashed window while the stash
404-// shows over the cover (a "Save as…" of the app the user picked there).
404+// shows over the cover (a "Save as…" of the app the user picked there),
405+// or a dialog of such a dialog (an "Overwrite?" over the "Save as…").
405406 func (w *Workspace) overCover(f Float) bool {
406407 c := w.cover()
407- if c == 0 || f.ID == c || f.over == 0 {
408+ if c == 0 || f.ID == c {
408409 return false
409410 }
410- return f.over == c || w.stashOverCover() && w.stashIndex(f.over) >= 0
411+ over := w.stashOverCover()
412+ // Each hop goes to another float: at most len(w.Floats) hops.
413+ for range len(w.Floats) {
414+ if f.over == 0 || f.over == f.ID {
415+ return false
416+ }
417+ if f.over == c || over && w.stashIndex(f.over) >= 0 {
418+ return true
419+ }
420+ i := w.floatIndex(f.over)
421+ if i < 0 {
422+ return false
423+ }
424+ f = w.Floats[i]
425+ }
426+ return false
411427 }
412428
413429 // coverDialog is the topmost dialog shown over the covering fullscreen
@@ -683,7 +699,8 @@ func (w *Workspace) FocusColumn(dir int) bool {
683699 from, _ := w.Focused()
684700 defer w.focusModal(from)
685701 if w.coverDialogFocused() {
686- // The first move goes back to the fullscreen window under it.
702+ // The first move goes back to the window under it: the stash
703+ // over the cover, or the cover.
687704 w.floatFocus = false
688705 return true
689706 }
@@ -401,13 +401,29 @@ func (w *Workspace) focusModal(from WindowID) {
401 401
402 // overCover reports whether float f is drawn over the covering fullscreen402 // overCover reports whether float f is drawn over the covering fullscreen
403 // window: a dialog of the cover, or of a stashed window while the stash403 // window: a dialog of the cover, or of a stashed window while the stash
404-// shows over the cover (a "Save as…" of the app the user picked there).404+// shows over the cover (a "Save as…" of the app the user picked there),
405+// or a dialog of such a dialog (an "Overwrite?" over the "Save as…").
405 func (w *Workspace) overCover(f Float) bool {406 func (w *Workspace) overCover(f Float) bool {
406 c := w.cover()407 c := w.cover()
407- if c == 0 || f.ID == c || f.over == 0 {408+ if c == 0 || f.ID == c {
408 return false409 return false
409 }410 }
410- return f.over == c || w.stashOverCover() && w.stashIndex(f.over) >= 0411+ over := w.stashOverCover()
412+ // Each hop goes to another float: at most len(w.Floats) hops.
413+ for range len(w.Floats) {
414+ if f.over == 0 || f.over == f.ID {
415+ return false
416+ }
417+ if f.over == c || over && w.stashIndex(f.over) >= 0 {
418+ return true
419+ }
420+ i := w.floatIndex(f.over)
421+ if i < 0 {
422+ return false
423+ }
424+ f = w.Floats[i]
425+ }
426+ return false
411 }427 }
412 428
413 // coverDialog is the topmost dialog shown over the covering fullscreen429 // coverDialog is the topmost dialog shown over the covering fullscreen
@@ -683,7 +699,8 @@ func (w *Workspace) FocusColumn(dir int) bool {
683 from, _ := w.Focused()699 from, _ := w.Focused()
684 defer w.focusModal(from)700 defer w.focusModal(from)
685 if w.coverDialogFocused() {701 if w.coverDialogFocused() {
686- // The first move goes back to the fullscreen window under it.702+ // The first move goes back to the window under it: the stash
703+ // over the cover, or the cover.
687 w.floatFocus = false704 w.floatFocus = false
688 return true705 return true
689 }706 }

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