detached 29cd2a5

fix(layout): ignore tiles under panels for line halves and pulse

Separators count only tiles with a visible neighbor, and the focus pulse
counts windows in the usable area. A cascade band above a panel lights its
half of a shared line again.
commit
29cd2a57237c240b61be408af29f3295266ee67d
author
brice · · 2026-10-07 14:17 UTC
parent
4079024
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +22 -12
M internal/core/core.go +2 -1
@@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
632632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
633633 }
634634 // A window alone on screen needs no pulse to show it has the focus.
635- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1
635+ // A tile under a panel (the next cascade band) does not count.
636+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
636637 for k, p := range layout {
637638 ps := settled[k]
638639 // Only the focused output has an activated window.
@@ -632,7 +632,8 @@ func (c *Core) publishFrame(ctx context.Context, only *screen) error {
632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)632 scene.Separators = separators(layout, c.cfg.Border.Width, sc.mon.Current().gap(), frame, i == c.focusScreen)
633 }633 }
634 // A window alone on screen needs no pulse to show it has the focus.634 // A window alone on screen needs no pulse to show it has the focus.
635- alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, frame) == 1635+ // A tile under a panel (the next cascade band) does not count.
636+ alone := i == c.focusScreen && c.pulse.target != 0 && visibleCount(layout, sc.mon.Current().Usable) == 1
636 for k, p := range layout {637 for k, p := range layout {
637 ps := settled[k]638 ps := settled[k]
638 // Only the focused output has an activated window.639 // Only the focused output has an activated window.
M internal/core/layout.go +10 -10
@@ -122,7 +122,7 @@ type Placement struct {
122122 // Preview is the scale of an overview preview: its buffer is drawn
123123 // that much smaller in Rect, the client keeps its size. 0 otherwise.
124124 Preview float64
125- // Neighbors are the sides touching a visible tile on this output.
125+ // Neighbors are the sides touching a tile visible in the usable area.
126126 // Inset reserves room for drawn lines: all sides of a float; for
127127 // tiles without gaps, only the right and bottom shared sides.
128128 Neighbors, Inset ports.Sides
@@ -1715,11 +1715,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
17151715 }
17161716
17171717 // setVisibleNeighbors reserves client space only for shared lines that
1718-// are actually on this output. A scrolled-off or hidden tile cannot give a
1719-// lone visible tile a border (or shrink its client).
1720-func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
1718+// are actually in the usable area. A scrolled-off, hidden or under-panel
1719+// tile cannot give a lone visible tile a border (or shrink its client).
1720+func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
17211721 visible := func(p Placement) bool {
1722- return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output)
1722+ return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area)
17231723 }
17241724 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
17251725 for i := range tiles {
@@ -1733,15 +1733,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
17331733 continue
17341734 }
17351735 b, r := tiles[j].Rect, a.Rect
1736- // Only count a shared edge strictly within this output.
1736+ // Only count a shared edge strictly within the area.
17371737 switch {
1738- case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W:
1738+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W:
17391739 a.Neighbors |= ports.SideRight
1740- case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W:
1740+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W:
17411741 a.Neighbors |= ports.SideLeft
1742- case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H:
1742+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H:
17431743 a.Neighbors |= ports.SideBottom
1744- case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H:
1744+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H:
17451745 a.Neighbors |= ports.SideTop
17461746 }
17471747 }
@@ -122,7 +122,7 @@ type Placement struct {
122 // Preview is the scale of an overview preview: its buffer is drawn122 // Preview is the scale of an overview preview: its buffer is drawn
123 // that much smaller in Rect, the client keeps its size. 0 otherwise.123 // that much smaller in Rect, the client keeps its size. 0 otherwise.
124 Preview float64124 Preview float64
125- // Neighbors are the sides touching a visible tile on this output.125+ // Neighbors are the sides touching a tile visible in the usable area.
126 // Inset reserves room for drawn lines: all sides of a float; for126 // Inset reserves room for drawn lines: all sides of a float; for
127 // tiles without gaps, only the right and bottom shared sides.127 // tiles without gaps, only the right and bottom shared sides.
128 Neighbors, Inset ports.Sides128 Neighbors, Inset ports.Sides
@@ -1715,11 +1715,11 @@ func (w *Workspace) appendLayout(dst []Placement) []Placement {
1715 }1715 }
1716 1716
1717 // setVisibleNeighbors reserves client space only for shared lines that1717 // setVisibleNeighbors reserves client space only for shared lines that
1718-// are actually on this output. A scrolled-off or hidden tile cannot give a1718+// are actually in the usable area. A scrolled-off, hidden or under-panel
1719-// lone visible tile a border (or shrink its client).1719+// tile cannot give a lone visible tile a border (or shrink its client).
1720-func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {1720+func setVisibleNeighbors(tiles []Placement, gap int, area Rect) {
1721 visible := func(p Placement) bool {1721 visible := func(p Placement) bool {
1722- return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(output)1722+ return !p.Hidden && !p.Fullscreen && p.Rect.Overlaps(area)
1723 }1723 }
1724 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }1724 overlap := func(a0, a1, b0, b1 int) bool { return a0 < b1 && b0 < a1 }
1725 for i := range tiles {1725 for i := range tiles {
@@ -1733,15 +1733,15 @@ func setVisibleNeighbors(tiles []Placement, gap int, output Rect) {
1733 continue1733 continue
1734 }1734 }
1735 b, r := tiles[j].Rect, a.Rect1735 b, r := tiles[j].Rect, a.Rect
1736- // Only count a shared edge strictly within this output.1736+ // Only count a shared edge strictly within the area.
1737 switch {1737 switch {
1738- case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > output.X && r.X+r.W < output.X+output.W:1738+ case r.X+r.W+gap == b.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X+r.W > area.X && r.X+r.W < area.X+area.W:
1739 a.Neighbors |= ports.SideRight1739 a.Neighbors |= ports.SideRight
1740- case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > output.X && r.X < output.X+output.W:1740+ case b.X+b.W+gap == r.X && overlap(r.Y, r.Y+r.H, b.Y, b.Y+b.H) && r.X > area.X && r.X < area.X+area.W:
1741 a.Neighbors |= ports.SideLeft1741 a.Neighbors |= ports.SideLeft
1742- case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > output.Y && r.Y+r.H < output.Y+output.H:1742+ case r.Y+r.H+gap == b.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y+r.H > area.Y && r.Y+r.H < area.Y+area.H:
1743 a.Neighbors |= ports.SideBottom1743 a.Neighbors |= ports.SideBottom
1744- case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > output.Y && r.Y < output.Y+output.H:1744+ case b.Y+b.H+gap == r.Y && overlap(r.X, r.X+r.W, b.X, b.X+b.W) && r.Y > area.Y && r.Y < area.Y+area.H:
1745 a.Neighbors |= ports.SideTop1745 a.Neighbors |= ports.SideTop
1746 }1746 }
1747 }1747 }
M internal/core/separators.go +3 -1
@@ -54,7 +54,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
5454 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
5555 continue
5656 }
57- if !p.Floating {
57+ // Tiles sharing a line: Neighbors only counts tiles visible in
58+ // the usable area, not those under a panel.
59+ if !p.Floating && p.Neighbors != 0 {
5860 tiles++
5961 }
6062 for _, s := range sides {
@@ -54,7 +54,9 @@ func separators(ps []Placement, width, gap int, o Rect, lit bool) []ports.Separa
54 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {54 if p.Fullscreen || !(onScreen(*p, o) || p.Leaving && p.Floating && p.Rect.Overlaps(o)) {
55 continue55 continue
56 }56 }
57- if !p.Floating {57+ // Tiles sharing a line: Neighbors only counts tiles visible in
58+ // the usable area, not those under a panel.
59+ if !p.Floating && p.Neighbors != 0 {
58 tiles++60 tiles++
59 }61 }
60 for _, s := range sides {62 for _, s := range sides {
M internal/core/separators_test.go +7 -0
@@ -166,9 +166,16 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
166166 if got := colorAt(seps, 200, 578); got != "" {
167167 t.Fatalf("line along the panel: %v", seps)
168168 }
169+ // Two tiles on screen: each lights its half of the shared line.
169170 if got := colorAt(seps, 449, 100); got != "lit" {
170171 t.Fatalf("shared line (449,100) = %q; %v", got, seps)
171172 }
173+ if got := colorAt(seps, 449, 400); got != "gray" {
174+ t.Fatalf("shared line (449,400) = %q; %v", got, seps)
175+ }
176+ if n := visibleCount(ps, w.Usable); n != 2 {
177+ t.Fatalf("visible in the usable area: %d, want 2", n)
178+ }
172179 }
173180
174181 // With gaps each tile has its own line: the focused one lights it whole.
@@ -166,9 +166,16 @@ func TestSeparatorsCascadeBottomPanel(t *testing.T) {
166 if got := colorAt(seps, 200, 578); got != "" {166 if got := colorAt(seps, 200, 578); got != "" {
167 t.Fatalf("line along the panel: %v", seps)167 t.Fatalf("line along the panel: %v", seps)
168 }168 }
169+ // Two tiles on screen: each lights its half of the shared line.
169 if got := colorAt(seps, 449, 100); got != "lit" {170 if got := colorAt(seps, 449, 100); got != "lit" {
170 t.Fatalf("shared line (449,100) = %q; %v", got, seps)171 t.Fatalf("shared line (449,100) = %q; %v", got, seps)
171 }172 }
173+ if got := colorAt(seps, 449, 400); got != "gray" {
174+ t.Fatalf("shared line (449,400) = %q; %v", got, seps)
175+ }
176+ if n := visibleCount(ps, w.Usable); n != 2 {
177+ t.Fatalf("visible in the usable area: %d, want 2", n)
178+ }
172 }179 }
173 180
174 // With gaps each tile has its own line: the focused one lights it whole.181 // With gaps each tile has its own line: the focused one lights it whole.

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 26

Tags 10

↑↓ moveenter openesc close