detached e06b149

fix(core): hide tiles when panels leave no usable area

A zero TileClip means no clip, so an empty usable area could not clip
the tiles: core now hides them in the scene. Tests cover tile lines and
pointer bounds of a tile across a panel, and check that the renderer's
tile rule matches core's area.
commit
e06b149193cf36e7fb17809d700d3a8ae4bc0fb5
author
brice · · 2026-10-08 14:08 UTC
parent
1466f78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +67 -6
M internal/core/scene.go +8 -3
@@ -22,9 +22,11 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
2222 // windows are going, and alone sizes the configures.
2323 layout, settled := sc.shown, sc.settledLayout
2424 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25+ // Tiles stop at the panels; the overview lays out previews over the
26+ // whole output. Panels leaving no room at all hide the tiles: a zero
27+ // TileClip would not clip.
28+ noTiles := !sc.mon.ov.open && view.usable.W*view.usable.H <= 0
2529 if !sc.mon.ov.open && view.usable != view.frame {
26- // Tiles stop at the panels; the overview lays out previews over
27- // the whole output.
2830 scene.TileClip = view.usable
2931 }
3032 var real map[WindowID]Placement
@@ -66,6 +68,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
6668 // Only the focused output has an activated window.
6769 focused := p.Focused && i == c.focusScreen
6870 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
71+ if noTiles && sw.Tile() {
72+ sw.Hidden = true
73+ }
6974 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
7075 // A scale motion: the content follows the drawn size. A
7176 // card in flight drawn at its size still needs Zoom 1:
@@ -77,7 +82,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
7782 if p.Leaving {
7883 // A window fading out after it closed or hid: drawn, but
7984 // hidden to everything else, its configures included.
80- sw.Hidden = false
85+ sw.Hidden = noTiles && sw.Tile()
8186 scene.Windows = append(scene.Windows, sw)
8287 // A window the workspace still holds (a hidden stash
8388 // window) keeps its last configure: prune would forget it,
@@ -22,9 +22,11 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
22 // windows are going, and alone sizes the configures.22 // windows are going, and alone sizes the configures.
23 layout, settled := sc.shown, sc.settledLayout23 layout, settled := sc.shown, sc.settledLayout
24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}24 scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)), Layers: shownLayers(sc)}
25+ // Tiles stop at the panels; the overview lays out previews over the
26+ // whole output. Panels leaving no room at all hide the tiles: a zero
27+ // TileClip would not clip.
28+ noTiles := !sc.mon.ov.open && view.usable.W*view.usable.H <= 0
25 if !sc.mon.ov.open && view.usable != view.frame {29 if !sc.mon.ov.open && view.usable != view.frame {
26- // Tiles stop at the panels; the overview lays out previews over
27- // the whole output.
28 scene.TileClip = view.usable30 scene.TileClip = view.usable
29 }31 }
30 var real map[WindowID]Placement32 var real map[WindowID]Placement
@@ -66,6 +68,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
66 // Only the focused output has an activated window.68 // Only the focused output has an activated window.
67 focused := p.Focused && i == c.focusScreen69 focused := p.Focused && i == c.focusScreen
68 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}70 sw := ports.SceneWindow{ID: p.ID, Rect: p.Rect, Focused: focused, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview, Fade: p.Fade}
71+ if noTiles && sw.Tile() {
72+ sw.Hidden = true
73+ }
69 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {74 if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) {
70 // A scale motion: the content follows the drawn size. A75 // A scale motion: the content follows the drawn size. A
71 // card in flight drawn at its size still needs Zoom 1:76 // card in flight drawn at its size still needs Zoom 1:
@@ -77,7 +82,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
77 if p.Leaving {82 if p.Leaving {
78 // A window fading out after it closed or hid: drawn, but83 // A window fading out after it closed or hid: drawn, but
79 // hidden to everything else, its configures included.84 // hidden to everything else, its configures included.
80- sw.Hidden = false85+ sw.Hidden = noTiles && sw.Tile()
81 scene.Windows = append(scene.Windows, sw)86 scene.Windows = append(scene.Windows, sw)
82 // A window the workspace still holds (a hidden stash87 // A window the workspace still holds (a hidden stash
83 // window) keeps its last configure: prune would forget it,88 // window) keeps its last configure: prune would forget it,
M internal/core/separators.go +3 -3
@@ -31,9 +31,9 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
3131 // A line cannot belong to a scrolled-off window, bleed onto
3232 // another output or, for a tile, over a panel, even when a
3333 // neighboring tile remains visible.
34- o := v.area(*p)
35- x0, y0 := max(r.X, o.X), max(r.Y, o.Y)
36- x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)
34+ a := v.area(*p)
35+ x0, y0 := max(r.X, a.X), max(r.Y, a.Y)
36+ x1, y1 := min(r.X+r.W, a.X+a.W), min(r.Y+r.H, a.Y+a.H)
3737 if x0 >= x1 || y0 >= y1 {
3838 return
3939 }
@@ -31,9 +31,9 @@ func separators(ps []Placement, width, gap int, v viewport, lit bool) []ports.Se
31 // A line cannot belong to a scrolled-off window, bleed onto31 // A line cannot belong to a scrolled-off window, bleed onto
32 // another output or, for a tile, over a panel, even when a32 // another output or, for a tile, over a panel, even when a
33 // neighboring tile remains visible.33 // neighboring tile remains visible.
34- o := v.area(*p)34+ a := v.area(*p)
35- x0, y0 := max(r.X, o.X), max(r.Y, o.Y)35+ x0, y0 := max(r.X, a.X), max(r.Y, a.Y)
36- x1, y1 := min(r.X+r.W, o.X+o.W), min(r.Y+r.H, o.Y+o.H)36+ x1, y1 := min(r.X+r.W, a.X+a.W), min(r.Y+r.H, a.Y+a.H)
37 if x0 >= x1 || y0 >= y1 {37 if x0 >= x1 || y0 >= y1 {
38 return38 return
39 }39 }
M internal/core/viewport_test.go +56 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "context"
5+ "slices"
56 "testing"
67
78 "github.com/bnema/neferwl/internal/ports"
@@ -124,6 +125,61 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
124125 check(map[WindowID]bool{3: true})
125126 }
126127
128+// A tile half under a bottom panel: its lines, its pointer bounds (warp,
129+// constraints) and its draw all stop at the panel, and the renderer's tile
130+// rule matches core's.
131+func TestTileStopsAtPanel(t *testing.T) {
132+ c, check := viewportCore(t, func(cfg *ports.Config) {
133+ cfg.Layout.MaxColumns = 2
134+ cfg.Border.Width = 2
135+ }, Rect{W: 100, H: 60})
136+ m := c.cur().mon
137+ m.AddWindow(1)
138+ m.AddWindow(3)
139+ m.Current().ToggleWindowStash() // 3 floats
140+ m.AddWindow(2)
141+ m.Current().FocusID(1)
142+ scene := check(map[WindowID]bool{1: true, 2: true, 3: true})
143+ // Move tile 1 across the panel line, as a slide would.
144+ sc := c.cur()
145+ sc.shown = slices.Clone(sc.shownLayout())
146+ for i := range sc.shown {
147+ if sc.shown[i].ID == 1 {
148+ sc.shown[i].Rect.Y = 30
149+ }
150+ }
151+ if r, ok := c.shownClientRect(1); !ok || r.Y+r.H > 60 {
152+ t.Fatalf("pointer bounds %+v %v reach over the panel", r, ok)
153+ }
154+ for _, s := range separators(sc.shown, 2, 0, m.viewport(), true) {
155+ if s.Window == 0 && s.Rect.Y+s.Rect.H > 60 {
156+ t.Fatalf("tile line over the panel: %+v", s)
157+ }
158+ }
159+ view := m.viewport()
160+ for k, sw := range scene.Windows {
161+ if sw.Popup {
162+ continue
163+ }
164+ if p := sc.settledLayout[k]; sw.Tile() != (view.area(p) == view.usable) {
165+ t.Fatalf("window %d: renderer tile %v, core area %+v", sw.ID, sw.Tile(), view.area(p))
166+ }
167+ }
168+}
169+
170+// Panels that leave no room hide every tile: a zero TileClip would not
171+// clip them.
172+func TestNoUsableAreaHidesTiles(t *testing.T) {
173+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
174+ c.cur().mon.AddWindow(1)
175+ scene := check(map[WindowID]bool{1: false})
176+ for _, sw := range scene.Windows {
177+ if sw.ID == 1 && !sw.Hidden {
178+ t.Fatalf("tile drawn with no usable area: %+v", sw)
179+ }
180+ }
181+}
182+
127183 // A column scrolled under a left panel is not seen.
128184 func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) {
129185 c, check := viewportCore(t, func(cfg *ports.Config) {
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "slices"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -124,6 +125,61 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) {
124 check(map[WindowID]bool{3: true})125 check(map[WindowID]bool{3: true})
125 }126 }
126 127
128+// A tile half under a bottom panel: its lines, its pointer bounds (warp,
129+// constraints) and its draw all stop at the panel, and the renderer's tile
130+// rule matches core's.
131+func TestTileStopsAtPanel(t *testing.T) {
132+ c, check := viewportCore(t, func(cfg *ports.Config) {
133+ cfg.Layout.MaxColumns = 2
134+ cfg.Border.Width = 2
135+ }, Rect{W: 100, H: 60})
136+ m := c.cur().mon
137+ m.AddWindow(1)
138+ m.AddWindow(3)
139+ m.Current().ToggleWindowStash() // 3 floats
140+ m.AddWindow(2)
141+ m.Current().FocusID(1)
142+ scene := check(map[WindowID]bool{1: true, 2: true, 3: true})
143+ // Move tile 1 across the panel line, as a slide would.
144+ sc := c.cur()
145+ sc.shown = slices.Clone(sc.shownLayout())
146+ for i := range sc.shown {
147+ if sc.shown[i].ID == 1 {
148+ sc.shown[i].Rect.Y = 30
149+ }
150+ }
151+ if r, ok := c.shownClientRect(1); !ok || r.Y+r.H > 60 {
152+ t.Fatalf("pointer bounds %+v %v reach over the panel", r, ok)
153+ }
154+ for _, s := range separators(sc.shown, 2, 0, m.viewport(), true) {
155+ if s.Window == 0 && s.Rect.Y+s.Rect.H > 60 {
156+ t.Fatalf("tile line over the panel: %+v", s)
157+ }
158+ }
159+ view := m.viewport()
160+ for k, sw := range scene.Windows {
161+ if sw.Popup {
162+ continue
163+ }
164+ if p := sc.settledLayout[k]; sw.Tile() != (view.area(p) == view.usable) {
165+ t.Fatalf("window %d: renderer tile %v, core area %+v", sw.ID, sw.Tile(), view.area(p))
166+ }
167+ }
168+}
169+
170+// Panels that leave no room hide every tile: a zero TileClip would not
171+// clip them.
172+func TestNoUsableAreaHidesTiles(t *testing.T) {
173+ c, check := viewportCore(t, func(cfg *ports.Config) { cfg.Layout.MaxColumns = 1 }, Rect{})
174+ c.cur().mon.AddWindow(1)
175+ scene := check(map[WindowID]bool{1: false})
176+ for _, sw := range scene.Windows {
177+ if sw.ID == 1 && !sw.Hidden {
178+ t.Fatalf("tile drawn with no usable area: %+v", sw)
179+ }
180+ }
181+}
182+
127 // A column scrolled under a left panel is not seen.183 // A column scrolled under a left panel is not seen.
128 func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) {184 func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) {
129 c, check := viewportCore(t, func(cfg *ports.Config) {185 c, check := viewportCore(t, func(cfg *ports.Config) {

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