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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 22 | 22 | // windows are going, and alone sizes the configures. | |
| 23 | 23 | layout, settled := sc.shown, sc.settledLayout | |
| 24 | 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 | 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 | 30 | scene.TileClip = view.usable | |
| 29 | 31 | } | |
| 30 | 32 | var real map[WindowID]Placement | |
| @@ -66,6 +68,9 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||
| 66 | 68 | // Only the focused output has an activated window. | |
| 67 | 69 | focused := p.Focused && i == c.focusScreen | |
| 68 | 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 | 74 | if p.Zoom > 0 && (p.Zoom < 1 || p.Preview > 0) { | |
| 70 | 75 | // A scale motion: the content follows the drawn size. A | |
| 71 | 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 | 82 | if p.Leaving { | |
| 78 | 83 | // A window fading out after it closed or hid: drawn, but | |
| 79 | 84 | // hidden to everything else, its configures included. | |
| 80 | - | sw.Hidden = false | |
| 85 | + | sw.Hidden = noTiles && sw.Tile() | |
| 81 | 86 | scene.Windows = append(scene.Windows, sw) | |
| 82 | 87 | // A window the workspace still holds (a hidden stash | |
| 83 | 88 | // 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.settledLayout | 23 | 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.usable | 30 | scene.TileClip = view.usable | ||
| 29 | } | 31 | } | ||
| 30 | var real map[WindowID]Placement | 32 | 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.focusScreen | 69 | 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. A | 75 | // 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, but | 83 | // 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 = false | 85 | + | 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 stash | 87 | // 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 | |||
| 31 | 31 | // A line cannot belong to a scrolled-off window, bleed onto | |
| 32 | 32 | // another output or, for a tile, over a panel, even when a | |
| 33 | 33 | // 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) | |
| 37 | 37 | if x0 >= x1 || y0 >= y1 { | |
| 38 | 38 | return | |
| 39 | 39 | } | |
| @@ -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 onto | 31 | // A line cannot belong to a scrolled-off window, bleed onto | ||
| 32 | // another output or, for a tile, over a panel, even when a | 32 | // 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 | return | 38 | return | ||
| 39 | } | 39 | } | ||
M internal/core/viewport_test.go +56 -0
| @@ -2,6 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -124,6 +125,61 @@ func TestConfigureVisibleCascadeUnderPanel(t *testing.T) { | |||
| 124 | 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 | 183 | // A column scrolled under a left panel is not seen. | |
| 128 | 184 | func TestConfigureVisibleScrollUnderSidePanel(t *testing.T) { | |
| 129 | 185 | 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) { | ||