fix(core): show stashed windows' dialogs over the cover with the stash
A modal dialog of a stashed window activated over a fullscreen cover made the cover leave fullscreen. While the stash shows over the cover, dialogs of stashed windows show over it too (overCover), so a game stays fullscreen.
- commit
- ee436b889db264bf2b7629de714b3c05c7b7eba7
- author
- brice · · 2026-10-13 04:11 UTC
- parent
- 1a34129
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +66 -15
M internal/core/floating_test.go +46 -0
| @@ -543,6 +543,52 @@ func TestActivateStashKeepsFullscreenFloat(t *testing.T) { | |||
| 543 | 543 | } | |
| 544 | 544 | } | |
| 545 | 545 | ||
| 546 | + | // A modal dialog of the activated stashed window shows over the cover, | |
| 547 | + | // focused, and the game stays fullscreen; closing it returns to the stash | |
| 548 | + | // over the cover. | |
| 549 | + | func TestActivateStashModalKeepsFullscreen(t *testing.T) { | |
| 550 | + | w := workspace() | |
| 551 | + | w.AddWindow(14) | |
| 552 | + | w.ToggleWindowStash() | |
| 553 | + | w.ToggleStashVisible() | |
| 554 | + | w.AddDialog(9, 14, 20, 10) | |
| 555 | + | w.SetModal(9, true) | |
| 556 | + | w.AddFloating(72, 50, 40) | |
| 557 | + | w.SetFullscreen(72, true) | |
| 558 | + | if p := placementOf(w.Layout(), 9); !p.Hidden { | |
| 559 | + | t.Fatalf("modal shown over the game before activation: %+v", p) | |
| 560 | + | } | |
| 561 | + | w.Activate(14) | |
| 562 | + | if id, _ := w.Focused(); id != 9 || w.fullscreen != 72 || w.left != 0 || !w.stashOverCover() { | |
| 563 | + | t.Fatalf("focused %d fullscreen %d left %d over %v", id, w.fullscreen, w.left, w.stashOverCover()) | |
| 564 | + | } | |
| 565 | + | layout := w.Layout() | |
| 566 | + | if p := placementOf(layout, 9); p.Hidden || p.Rect.W == 0 { | |
| 567 | + | t.Fatalf("modal hidden: %+v", p) | |
| 568 | + | } | |
| 569 | + | if p := placementOf(layout, 72); !p.Fullscreen { | |
| 570 | + | t.Fatalf("game left fullscreen: %+v", p) | |
| 571 | + | } | |
| 572 | + | if placementIndex(layout, 9) < placementIndex(layout, 14) || placementIndex(layout, 14) < placementIndex(layout, 72) { | |
| 573 | + | t.Fatalf("order: game %d stash %d modal %d", placementIndex(layout, 72), placementIndex(layout, 14), placementIndex(layout, 9)) | |
| 574 | + | } | |
| 575 | + | w.RemoveWindow(9) | |
| 576 | + | if id, _ := w.Focused(); id != 14 || w.fullscreen != 72 || !w.stashOverCover() { | |
| 577 | + | t.Fatalf("closed: focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover()) | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | func placementOf(ps []Placement, id WindowID) Placement { | |
| 582 | + | if i := placementIndex(ps, id); i >= 0 { | |
| 583 | + | return ps[i] | |
| 584 | + | } | |
| 585 | + | return Placement{} | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | func placementIndex(ps []Placement, id WindowID) int { | |
| 589 | + | return slices.IndexFunc(ps, func(p Placement) bool { return p.ID == id }) | |
| 590 | + | } | |
| 591 | + | ||
| 546 | 592 | // With the stash already over the cover and a dialog of the cover | |
| 547 | 593 | // focused, activating a stashed window focuses it. | |
| 548 | 594 | func TestActivateStashOverCoverFromCoverDialog(t *testing.T) { | |
| @@ -543,6 +543,52 @@ func TestActivateStashKeepsFullscreenFloat(t *testing.T) { | |||||
| 543 | } | 543 | } | ||
| 544 | } | 544 | } | ||
| 545 | 545 | ||||
| 546 | + | // A modal dialog of the activated stashed window shows over the cover, | |||
| 547 | + | // focused, and the game stays fullscreen; closing it returns to the stash | |||
| 548 | + | // over the cover. | |||
| 549 | + | func TestActivateStashModalKeepsFullscreen(t *testing.T) { | |||
| 550 | + | w := workspace() | |||
| 551 | + | w.AddWindow(14) | |||
| 552 | + | w.ToggleWindowStash() | |||
| 553 | + | w.ToggleStashVisible() | |||
| 554 | + | w.AddDialog(9, 14, 20, 10) | |||
| 555 | + | w.SetModal(9, true) | |||
| 556 | + | w.AddFloating(72, 50, 40) | |||
| 557 | + | w.SetFullscreen(72, true) | |||
| 558 | + | if p := placementOf(w.Layout(), 9); !p.Hidden { | |||
| 559 | + | t.Fatalf("modal shown over the game before activation: %+v", p) | |||
| 560 | + | } | |||
| 561 | + | w.Activate(14) | |||
| 562 | + | if id, _ := w.Focused(); id != 9 || w.fullscreen != 72 || w.left != 0 || !w.stashOverCover() { | |||
| 563 | + | t.Fatalf("focused %d fullscreen %d left %d over %v", id, w.fullscreen, w.left, w.stashOverCover()) | |||
| 564 | + | } | |||
| 565 | + | layout := w.Layout() | |||
| 566 | + | if p := placementOf(layout, 9); p.Hidden || p.Rect.W == 0 { | |||
| 567 | + | t.Fatalf("modal hidden: %+v", p) | |||
| 568 | + | } | |||
| 569 | + | if p := placementOf(layout, 72); !p.Fullscreen { | |||
| 570 | + | t.Fatalf("game left fullscreen: %+v", p) | |||
| 571 | + | } | |||
| 572 | + | if placementIndex(layout, 9) < placementIndex(layout, 14) || placementIndex(layout, 14) < placementIndex(layout, 72) { | |||
| 573 | + | t.Fatalf("order: game %d stash %d modal %d", placementIndex(layout, 72), placementIndex(layout, 14), placementIndex(layout, 9)) | |||
| 574 | + | } | |||
| 575 | + | w.RemoveWindow(9) | |||
| 576 | + | if id, _ := w.Focused(); id != 14 || w.fullscreen != 72 || !w.stashOverCover() { | |||
| 577 | + | t.Fatalf("closed: focused %d fullscreen %d over %v", id, w.fullscreen, w.stashOverCover()) | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | func placementOf(ps []Placement, id WindowID) Placement { | |||
| 582 | + | if i := placementIndex(ps, id); i >= 0 { | |||
| 583 | + | return ps[i] | |||
| 584 | + | } | |||
| 585 | + | return Placement{} | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func placementIndex(ps []Placement, id WindowID) int { | |||
| 589 | + | return slices.IndexFunc(ps, func(p Placement) bool { return p.ID == id }) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 546 | // With the stash already over the cover and a dialog of the cover | 592 | // With the stash already over the cover and a dialog of the cover | ||
| 547 | // focused, activating a stashed window focuses it. | 593 | // focused, activating a stashed window focuses it. | ||
| 548 | func TestActivateStashOverCoverFromCoverDialog(t *testing.T) { | 594 | func TestActivateStashOverCoverFromCoverDialog(t *testing.T) { | ||
M internal/core/layout.go +20 -15
| @@ -390,7 +390,7 @@ func (w *Workspace) focusModal(from WindowID) { | |||
| 390 | 390 | return | |
| 391 | 391 | } | |
| 392 | 392 | if c := w.cover(); c != 0 && c != d { | |
| 393 | - | if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) { | |
| 393 | + | if i := w.floatIndex(d); i >= 0 && !w.overCover(w.Floats[i]) { | |
| 394 | 394 | // Hidden under another window's fullscreen: leave it, as | |
| 395 | 395 | // Activate does, so the dialog shows. | |
| 396 | 396 | w.leaveFullscreen() | |
| @@ -399,22 +399,26 @@ func (w *Workspace) focusModal(from WindowID) { | |||
| 399 | 399 | w.FocusID(d) | |
| 400 | 400 | } | |
| 401 | 401 | ||
| 402 | - | // coverDialog reports whether f is a dialog of the covering fullscreen | |
| 403 | - | // window cover: the one float drawn over it. | |
| 404 | - | func (f Float) coverDialog(cover WindowID) bool { | |
| 405 | - | return cover != 0 && f.over == cover && f.ID != cover | |
| 402 | + | // 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 stash | |
| 404 | + | // shows over the cover (a "Save as…" of the app the user picked there). | |
| 405 | + | func (w *Workspace) overCover(f Float) bool { | |
| 406 | + | c := w.cover() | |
| 407 | + | if c == 0 || f.ID == c || f.over == 0 { | |
| 408 | + | return false | |
| 409 | + | } | |
| 410 | + | return f.over == c || w.stashOverCover() && w.stashIndex(f.over) >= 0 | |
| 406 | 411 | } | |
| 407 | 412 | ||
| 408 | 413 | // coverDialog is the topmost dialog shown over the covering fullscreen | |
| 409 | - | // window when the floats have the focus, else 0. The floats it hides cannot | |
| 410 | - | // take the focus. | |
| 414 | + | // window (overCover) when the floats have the focus, else 0. The floats it | |
| 415 | + | // hides cannot take the focus. | |
| 411 | 416 | func (w *Workspace) coverDialog() WindowID { | |
| 412 | 417 | if !w.floatFocus { | |
| 413 | 418 | return 0 | |
| 414 | 419 | } | |
| 415 | - | cover := w.cover() | |
| 416 | 420 | for i := len(w.Floats) - 1; i >= 0; i-- { | |
| 417 | - | if f := w.Floats[i]; !f.below && f.coverDialog(cover) { | |
| 421 | + | if f := w.Floats[i]; !f.below && w.overCover(f) { | |
| 418 | 422 | return f.ID | |
| 419 | 423 | } | |
| 420 | 424 | } | |
| @@ -638,9 +642,9 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||
| 638 | 642 | f := w.Floats[i] | |
| 639 | 643 | f.below = false | |
| 640 | 644 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | |
| 641 | - | if !f.coverDialog(w.cover()) { | |
| 642 | - | // A dialog of the cover shows over the stash; leaving it | |
| 643 | - | // returns there. | |
| 645 | + | if !w.overCover(f) { | |
| 646 | + | // A dialog shown over the cover keeps the stash over it; | |
| 647 | + | // leaving it returns there. | |
| 644 | 648 | w.dropStashOver() | |
| 645 | 649 | } | |
| 646 | 650 | w.floatFocus = true | |
| @@ -1788,9 +1792,10 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||
| 1788 | 1792 | if order != group { | |
| 1789 | 1793 | continue | |
| 1790 | 1794 | } | |
| 1791 | - | // A covering fullscreen window hides every float but its own | |
| 1792 | - | // dialogs: the others show again when it leaves fullscreen. | |
| 1793 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)} | |
| 1795 | + | // A covering fullscreen window hides every float but the | |
| 1796 | + | // dialogs over it (overCover): the others show again when it | |
| 1797 | + | // leaves fullscreen. | |
| 1798 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)} | |
| 1794 | 1799 | if p.Hidden { | |
| 1795 | 1800 | p.Rect = Rect{} | |
| 1796 | 1801 | result = append(result, p) | |
| @@ -390,7 +390,7 @@ func (w *Workspace) focusModal(from WindowID) { | |||||
| 390 | return | 390 | return | ||
| 391 | } | 391 | } | ||
| 392 | if c := w.cover(); c != 0 && c != d { | 392 | if c := w.cover(); c != 0 && c != d { | ||
| 393 | - | if i := w.floatIndex(d); i >= 0 && !w.Floats[i].coverDialog(c) { | 393 | + | if i := w.floatIndex(d); i >= 0 && !w.overCover(w.Floats[i]) { |
| 394 | // Hidden under another window's fullscreen: leave it, as | 394 | // Hidden under another window's fullscreen: leave it, as | ||
| 395 | // Activate does, so the dialog shows. | 395 | // Activate does, so the dialog shows. | ||
| 396 | w.leaveFullscreen() | 396 | w.leaveFullscreen() | ||
| @@ -399,22 +399,26 @@ func (w *Workspace) focusModal(from WindowID) { | |||||
| 399 | w.FocusID(d) | 399 | w.FocusID(d) | ||
| 400 | } | 400 | } | ||
| 401 | 401 | ||||
| 402 | - | // coverDialog reports whether f is a dialog of the covering fullscreen | 402 | + | // overCover reports whether float f is drawn over the covering fullscreen |
| 403 | - | // window cover: the one float drawn over it. | 403 | + | // window: a dialog of the cover, or of a stashed window while the stash |
| 404 | - | func (f Float) coverDialog(cover WindowID) bool { | 404 | + | // shows over the cover (a "Save as…" of the app the user picked there). |
| 405 | - | return cover != 0 && f.over == cover && f.ID != cover | 405 | + | func (w *Workspace) overCover(f Float) bool { |
| 406 | + | c := w.cover() | |||
| 407 | + | if c == 0 || f.ID == c || f.over == 0 { | |||
| 408 | + | return false | |||
| 409 | + | } | |||
| 410 | + | return f.over == c || w.stashOverCover() && w.stashIndex(f.over) >= 0 | |||
| 406 | } | 411 | } | ||
| 407 | 412 | ||||
| 408 | // coverDialog is the topmost dialog shown over the covering fullscreen | 413 | // coverDialog is the topmost dialog shown over the covering fullscreen | ||
| 409 | - | // window when the floats have the focus, else 0. The floats it hides cannot | 414 | + | // window (overCover) when the floats have the focus, else 0. The floats it |
| 410 | - | // take the focus. | 415 | + | // hides cannot take the focus. |
| 411 | func (w *Workspace) coverDialog() WindowID { | 416 | func (w *Workspace) coverDialog() WindowID { | ||
| 412 | if !w.floatFocus { | 417 | if !w.floatFocus { | ||
| 413 | return 0 | 418 | return 0 | ||
| 414 | } | 419 | } | ||
| 415 | - | cover := w.cover() | |||
| 416 | for i := len(w.Floats) - 1; i >= 0; i-- { | 420 | for i := len(w.Floats) - 1; i >= 0; i-- { | ||
| 417 | - | if f := w.Floats[i]; !f.below && f.coverDialog(cover) { | 421 | + | if f := w.Floats[i]; !f.below && w.overCover(f) { |
| 418 | return f.ID | 422 | return f.ID | ||
| 419 | } | 423 | } | ||
| 420 | } | 424 | } | ||
| @@ -638,9 +642,9 @@ func (w *Workspace) FocusID(id WindowID) bool { | |||||
| 638 | f := w.Floats[i] | 642 | f := w.Floats[i] | ||
| 639 | f.below = false | 643 | f.below = false | ||
| 640 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | 644 | w.Floats = append(slices.Delete(w.Floats, i, i+1), f) | ||
| 641 | - | if !f.coverDialog(w.cover()) { | 645 | + | if !w.overCover(f) { |
| 642 | - | // A dialog of the cover shows over the stash; leaving it | 646 | + | // A dialog shown over the cover keeps the stash over it; |
| 643 | - | // returns there. | 647 | + | // leaving it returns there. |
| 644 | w.dropStashOver() | 648 | w.dropStashOver() | ||
| 645 | } | 649 | } | ||
| 646 | w.floatFocus = true | 650 | w.floatFocus = true | ||
| @@ -1788,9 +1792,10 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement { | |||||
| 1788 | if order != group { | 1792 | if order != group { | ||
| 1789 | continue | 1793 | continue | ||
| 1790 | } | 1794 | } | ||
| 1791 | - | // A covering fullscreen window hides every float but its own | 1795 | + | // A covering fullscreen window hides every float but the |
| 1792 | - | // dialogs: the others show again when it leaves fullscreen. | 1796 | + | // dialogs over it (overCover): the others show again when it |
| 1793 | - | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !f.coverDialog(cover)} | 1797 | + | // leaves fullscreen. |
| 1798 | + | p := Placement{ID: f.ID, Rect: w.floatRect(f), Floating: true, Below: below, Focused: f.ID == focusedID, Inset: ports.SideAll, Hidden: cover != 0 && f.ID != cover && !w.overCover(f)} | |||
| 1794 | if p.Hidden { | 1799 | if p.Hidden { | ||
| 1795 | p.Rect = Rect{} | 1800 | p.Rect = Rect{} | ||
| 1796 | result = append(result, p) | 1801 | result = append(result, p) | ||